diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx
index fac9921c..1a458ec7 100644
--- a/src/authz-module/audit-user/index.test.tsx
+++ b/src/authz-module/audit-user/index.test.tsx
@@ -30,6 +30,10 @@ jest.mock('@edx/frontend-component-header', () => ({
jest.mock('@src/data/hooks', () => ({
...jest.requireActual('@src/data/hooks'),
useUserAccount: jest.fn(),
+ useValidateUserPermissionsNonSuspense: jest.fn().mockReturnValue({
+ data: [{ scope: 'lib:test', allowed: true }],
+ isLoading: false,
+ }),
}));
// Mock the useRevokeUserRoles hook
diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx
index d5f45567..51d97c3f 100644
--- a/src/authz-module/audit-user/index.tsx
+++ b/src/authz-module/audit-user/index.tsx
@@ -5,15 +5,16 @@ import {
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import type { AppContextType } from '@edx/frontend-platform/react';
-import debounce from 'lodash.debounce';
import {
Container, DataTable,
} from '@openedx/paragon';
import TableFooter from '@src/authz-module/components/TableFooter/TableFooter';
-import { AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
+import {
+ AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE,
+} from '@src/authz-module/constants';
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
import { useNavigate, useParams } from 'react-router-dom';
-import { useUserAccount } from '@src/data/hooks';
+import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import baseMessages from '@src/authz-module/messages';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import {
@@ -30,7 +31,7 @@ import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilte
import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar';
import messages from './messages';
import ConfirmDeletionModal from '../components/ConfirmDeletionModal';
-import { getCellHeader } from '../components/utils';
+import { getCellHeader, getScopeManageActionPermission } from '../utils';
const AuditUserPage = () => {
const { formatMessage } = useIntl();
@@ -52,7 +53,30 @@ const AuditUserPage = () => {
} = useToastManager();
const { mutate: revokeUserRoles, isPending: isRevokingUserRolePending } = useRevokeUserRoles();
- const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]);
+ const deletePermissions = useMemo(() => {
+ const uniqueScopes = [...new Set(userAssignments.map(assignment => assignment.scope))];
+ return uniqueScopes.map(scope => getScopeManageActionPermission(scope));
+ }, [userAssignments]);
+
+ const {
+ data: permissionsToManageScope,
+ } = useValidateUserPermissionsNonSuspense(deletePermissions);
+
+ const rowsWithPermissions = useMemo(() => {
+ if (!permissionsToManageScope) { return userAssignments; }
+
+ return userAssignments.map(assignment => {
+ const canManageScope = permissionsToManageScope.some(
+ permission => permission.scope === assignment.scope && permission.allowed,
+ );
+ return {
+ ...assignment,
+ canManageScope,
+ };
+ });
+ }, [userAssignments, permissionsToManageScope]);
+
+ const fetchData = useMemo(() => handleTableFetch, [handleTableFetch]);
useEffect(() => {
if (!user && !isLoadingUser) {
@@ -63,8 +87,6 @@ const AuditUserPage = () => {
}
}, [user, isLoadingUser, navigate, isErrorUser, errorUser]);
- useEffect(() => () => fetchData.cancel(), [fetchData]);
-
const handleShowConfirmDeletionModal = useCallback((role: RoleToDelete) => {
if (isRevokingUserRolePending) { return; }
@@ -227,7 +249,7 @@ const AuditUserPage = () => {
isFilterable
isSortable
manualPagination
- data={userAssignments}
+ data={rowsWithPermissions}
manualFilters
manualSortBy
fetchData={fetchData}
diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx
index 052a0a94..2b3ae2de 100644
--- a/src/authz-module/components/TableCells.test.tsx
+++ b/src/authz-module/components/TableCells.test.tsx
@@ -14,10 +14,6 @@ import {
createActionsCell,
} from './TableCells';
-// TODO: remove console.log mocks and implement actual logic for these cells, then update tests accordingly
-// Mock console.log for TODO functions
-jest.spyOn(console, 'log').mockImplementation(() => {});
-
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -490,6 +486,7 @@ describe('TableCells Components', () => {
org: 'Test Org',
scope: 'Test Scope',
permissionCount: 1,
+ canManageScope: true,
},
};
@@ -512,7 +509,26 @@ describe('TableCells Components', () => {
expect(mockOnClickDeleteButton).toHaveBeenCalledWith({ name: 'Library Admin', role: 'library_admin', scope: 'Test Scope' });
});
- it('renders a disabled button for admin roles when isUserAuthenticatedPage is true', () => {
+ it('renders a disabled delete icon for admin roles when isUserAuthenticatedPage is true', () => {
+ const adminRow = {
+ original: {
+ role: 'course_admin',
+ org: 'Test Org',
+ scope: 'Test Scope',
+ permissionCount: 1,
+ },
+ };
+ const CustomActionsCell = createActionsCell({
+ onClickDeleteButton: mockOnClickDeleteButton,
+ isUserAuthenticatedPage: true,
+ });
+ renderWrapper();
+
+ const infoIcon = screen.getByRole('img', { hidden: true });
+ expect(infoIcon).toBeInTheDocument();
+ });
+
+ it('renders a tooltip when hovering over delete icon for admin roles when isUserAuthenticatedPage is true', async () => {
const adminRow = {
original: {
role: 'course_admin',
@@ -521,14 +537,16 @@ describe('TableCells Components', () => {
permissionCount: 1,
},
};
+ const user = userEvent.setup();
const CustomActionsCell = createActionsCell({
onClickDeleteButton: mockOnClickDeleteButton,
isUserAuthenticatedPage: true,
});
renderWrapper();
- const button = screen.getByRole('button', { name: /delete role action/i });
- expect(button).toBeDisabled();
+ const infoIcon = screen.getByRole('img', { hidden: true });
+ await user.hover(infoIcon);
+ expect(screen.getByText(/You can’t remove your own admin role/i)).toBeInTheDocument();
});
it('renders info icon with tooltip for Django managed roles', async () => {
@@ -552,6 +570,23 @@ describe('TableCells Components', () => {
await user.hover(infoIcon);
expect(screen.getByText(/Please go to Django Admin to manage it/i)).toBeInTheDocument();
});
+
+ it('renders a disabled button when user does not have permission', async () => {
+ const CustomActionsCell = createActionsCell({
+ onClickDeleteButton: mockOnClickDeleteButton,
+ isUserAuthenticatedPage: false,
+ });
+ const customRow = {
+ original: {
+ ...baseRow,
+ canManageScope: false,
+ },
+ };
+ renderWrapper();
+
+ const deleteButton = screen.queryByRole('button', { name: /delete role action/i });
+ expect(deleteButton).toBeDisabled();
+ });
});
describe('ViewAllPermissionsCell', () => {
diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx
index a0324fb3..42146cbb 100644
--- a/src/authz-module/components/TableCells.tsx
+++ b/src/authz-module/components/TableCells.tsx
@@ -6,11 +6,13 @@ import {
Info,
} from '@openedx/paragon/icons';
import {
- TableCellValue, AppContextType, UserRole, RoleToDelete,
+ TableCellValue, AppContextType, UserRoleWithPermissions, RoleToDelete,
} from '@src/types';
import { useNavigate } from 'react-router-dom';
import { useContext, useMemo } from 'react';
-import { ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants';
+import {
+ ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL,
+} from '@src/authz-module/constants';
import {
Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext,
} from '@openedx/paragon';
@@ -25,7 +27,7 @@ interface DataTableInstance {
toggleRowExpanded?: (rowId: string, expanded: boolean) => void;
}
-type CellProps = TableCellValue;
+type CellProps = TableCellValue;
type CellPropsWithValue = CellProps & {
value: string;
};
@@ -160,9 +162,11 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => {
);
};
-const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: ActionsCellProps) => {
+const ActionsCell = ({
+ row, onClickDeleteButton, isUserAuthenticatedPage,
+}: ActionsCellProps) => {
const { formatMessage } = useIntl();
- const { role } = row.original;
+ const { role, canManageScope } = row.original;
const handleDelete = () => {
const roleToDelete = {
@@ -193,19 +197,30 @@ const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: Acti
if (ADMIN_ROLES.includes(role) && isUserAuthenticatedPage) {
return (
-
+
+ {formatMessage(messages['authz.user.table.delete.action.adminrole.tooltip'])}
+
+ )}
+ >
+
+
);
}
return (
-
+
);
};
diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts
index a2895634..55f3b040 100644
--- a/src/authz-module/components/messages.ts
+++ b/src/authz-module/components/messages.ts
@@ -157,6 +157,11 @@ const messages = defineMessages({
defaultMessage: 'You can’t remove this role here. Please go to Django Admin to manage it.',
description: 'Tooltip for delete button when hovering over Django roles',
},
+ 'authz.user.table.delete.action.adminrole.tooltip': {
+ id: 'authz.user.table.delete.action.adminrole.tooltip',
+ defaultMessage: 'You can’t remove your own admin role. This prevents a resource from being left without an admin. Another user with the required permissions can revoke it.',
+ description: 'Tooltip for delete button when hovering over Admin roles',
+ },
'authz.user.table.view_all_permissions.link.text.close': {
id: 'authz.user.table.view_all_permissions.link.text.close',
defaultMessage: 'Hide all permissions',
diff --git a/src/authz-module/components/utils.test.tsx b/src/authz-module/components/utils.test.tsx
deleted file mode 100644
index 8c879710..00000000
--- a/src/authz-module/components/utils.test.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import { screen } from '@testing-library/react';
-import { initializeMockApp } from '@edx/frontend-platform/testing';
-import { renderWrapper } from '@src/setupTest';
-import { getCellHeader } from './utils';
-
-const renderCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
- const component = getCellHeader(columnId, columnTitle, filtersApplied);
- return renderWrapper({component}
);
-};
-
-describe('getCellHeader', () => {
- beforeEach(() => {
- initializeMockApp({
- authenticatedUser: {
- userId: 1,
- username: 'testuser',
- email: 'testuser@example.com',
- },
- });
- });
-
- it('displays column title without filter icon when no filters are applied', () => {
- const { container } = renderCellHeader('scope', 'Scope', []);
-
- expect(screen.getByText('Scope')).toBeInTheDocument();
- expect(container.querySelector('svg')).not.toBeInTheDocument();
- });
-
- it('displays column title without filter icon when column is not in filters applied', () => {
- const { container } = renderCellHeader('scope', 'Scope', ['role', 'organization']);
-
- expect(screen.getByText('Scope')).toBeInTheDocument();
- expect(container.querySelector('svg')).not.toBeInTheDocument();
- });
-
- it('displays column title with filter icon when column has filter applied', () => {
- const { container } = renderCellHeader('scope', 'Scope', ['scope', 'role']);
-
- expect(screen.getByText('Scope')).toBeInTheDocument();
- expect(container.querySelector('svg')).toBeInTheDocument();
- });
-
- it('displays filter icon only for matching column when multiple filters applied', () => {
- const { container } = renderCellHeader('organization', 'Organization', ['scope', 'organization', 'role']);
-
- expect(screen.getByText('Organization')).toBeInTheDocument();
- expect(container.querySelector('svg')).toBeInTheDocument();
- });
-
- it('handles empty column title', () => {
- const { container } = renderCellHeader('scope', '', ['scope']);
-
- expect(container.querySelector('svg')).toBeInTheDocument();
- });
-
- it('handles special characters in column title', () => {
- const { container } = renderCellHeader('scope', 'Scope & Context', ['scope']);
-
- expect(screen.getByText('Scope & Context')).toBeInTheDocument();
- expect(container.querySelector('svg')).toBeInTheDocument();
- });
-
- it('is case sensitive when matching column ID', () => {
- const { container } = renderCellHeader('scope', 'Scope', ['SCOPE', 'Role']);
-
- expect(screen.getByText('Scope')).toBeInTheDocument();
- expect(container.querySelector('svg')).not.toBeInTheDocument();
- });
-
- it('handles long column titles with filters', () => {
- const { container } = renderCellHeader('organization', 'Very Long Organization Column Title', ['organization']);
-
- expect(screen.getByText('Very Long Organization Column Title')).toBeInTheDocument();
- expect(container.querySelector('svg')).toBeInTheDocument();
- });
-
- it('displays correct structure when filter is applied', () => {
- renderCellHeader('role', 'Role', ['role']);
-
- const container = screen.getByText('Role').closest('span');
- expect(container).toHaveClass('d-flex', 'flex-row', 'align-items-center');
- });
-});
diff --git a/src/authz-module/components/utils.tsx b/src/authz-module/components/utils.tsx
deleted file mode 100644
index 4d0bd222..00000000
--- a/src/authz-module/components/utils.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Icon } from '@openedx/paragon';
-import { FilterList } from '@openedx/paragon/icons';
-
-export const getCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
- if (filtersApplied.includes(columnId)) {
- return (
-
-
- {columnTitle}
-
- );
- }
- return columnTitle;
-};
diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx
index ba7691dc..7d0361d9 100644
--- a/src/authz-module/data/hooks.test.tsx
+++ b/src/authz-module/data/hooks.test.tsx
@@ -687,6 +687,95 @@ describe('useRevokeUserRoles', () => {
expect(calledUrl.searchParams.get('role')).toBe(revokeRoleData.role);
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
});
+
+ it('invalidates userRoles queries on mutation completion', async () => {
+ const mockResponse = {
+ completed: [
+ {
+ userIdentifiers: 'jdoe',
+ status: 'role_removed',
+ },
+ ],
+ errors: [],
+ };
+
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ delete: jest.fn().mockResolvedValue({ data: mockResponse }),
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useRevokeUserRoles(), {
+ wrapper,
+ });
+
+ const revokeRoleData = {
+ scope: 'lib:123',
+ users: 'jdoe',
+ role: 'author',
+ };
+
+ await act(async () => {
+ result.current.mutate({ data: revokeRoleData });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({
+ predicate: expect.any(Function),
+ });
+
+ // Type workaround for SpyInstance type not matching function signature
+ type MockCall = Parameters[0];
+ type PredicateCall = MockCall & { predicate: (query: { queryKey: readonly unknown[] }) => boolean };
+
+ const predicateCalls = invalidateQueriesSpy.mock.calls
+ .map(call => call[0])
+ .filter((call): call is PredicateCall => call !== undefined
+ && 'predicate' in call
+ && typeof call.predicate === 'function');
+
+ const userRolesCall = predicateCalls.find(call => {
+ const { predicate } = call;
+ return predicate({ queryKey: ['test-app', 'authz', 'userRoles', 'testuser'] });
+ });
+
+ expect(userRolesCall).toBeDefined();
+
+ if (userRolesCall) {
+ const { predicate } = userRolesCall;
+ expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(true);
+ expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembers'] })).toBe(false);
+ }
+
+ const allRoleAssignmentsCall = predicateCalls.find(call => {
+ const { predicate } = call;
+ return predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments', {}] });
+ });
+
+ expect(allRoleAssignmentsCall).toBeDefined();
+
+ if (allRoleAssignmentsCall) {
+ const { predicate } = allRoleAssignmentsCall;
+ expect(predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments'] })).toBe(true);
+ expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(false);
+ }
+
+ invalidateQueriesSpy.mockRestore();
+ });
});
describe('useAllRoleAssignments', () => {
diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts
index a8a6e76d..62ec4122 100644
--- a/src/authz-module/data/hooks.ts
+++ b/src/authz-module/data/hooks.ts
@@ -3,7 +3,6 @@ import {
} from '@tanstack/react-query';
import { appId } from '@src/constants';
import { LibraryMetadata } from '@src/types';
-import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
import {
assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments,
GetAllRoleAssignmentsResponse, getLibrary, getOrgs, GetOrgsResponse,
@@ -129,18 +128,19 @@ export const useValidateUsers = () => useMutation({
*/
export const useRevokeUserRoles = () => {
const queryClient = useQueryClient();
- const { querySettings: defaultQuerySettings } = useQuerySettings();
return useMutation({
mutationFn: async ({ data }: {
data: RevokeUserRolesRequest
}) => revokeUserRoles(data),
- onSettled: (_data, _error, { data: { scope, users, querySettings } }) => {
+ onSettled: (_data, _error, { data: { scope } }) => {
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
queryClient.invalidateQueries({ queryKey: authzQueryKeys.permissionsByRole(scope) });
queryClient.invalidateQueries({
- queryKey: authzQueryKeys.userRoles(users, querySettings),
+ predicate: (query) => query.queryKey.includes('userRoles'),
+ });
+ queryClient.invalidateQueries({
+ predicate: (query) => query.queryKey.includes('allRoleAssignments'),
});
- queryClient.invalidateQueries({ queryKey: authzQueryKeys.allRoleAssignments(defaultQuerySettings) });
},
});
};
diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx
index b78f4470..dfdbce56 100644
--- a/src/authz-module/team-members/TeamMembersTable.tsx
+++ b/src/authz-module/team-members/TeamMembersTable.tsx
@@ -12,7 +12,7 @@ import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter';
import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilter';
import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter';
import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar';
-import { getCellHeader } from '@src/authz-module/components/utils';
+import { getCellHeader } from '@src/authz-module/utils';
import {
ViewActionCell, NameCell, OrgCell, RoleCell, ScopeCell,
} from '@src/authz-module/components/TableCells';
diff --git a/src/authz-module/utils.test.tsx b/src/authz-module/utils.test.tsx
new file mode 100644
index 00000000..502f141b
--- /dev/null
+++ b/src/authz-module/utils.test.tsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import { getCellHeader, getScopeManageAction, getScopeManageActionPermission } from './utils';
+import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './constants';
+
+const renderCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
+ const component = getCellHeader(columnId, columnTitle, filtersApplied);
+ return renderWrapper({component}
);
+};
+
+describe('utils', () => {
+ describe('getCellHeader', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'testuser@example.com',
+ },
+ });
+ });
+
+ it('displays column title without filter icon when no filters are applied', () => {
+ const { container } = renderCellHeader('scope', 'Scope', []);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeInTheDocument();
+ });
+
+ it('displays column title without filter icon when column is not in filters applied', () => {
+ const { container } = renderCellHeader('scope', 'Scope', ['role', 'organization']);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeInTheDocument();
+ });
+
+ it('displays column title with filter icon when column has filter applied', () => {
+ const { container } = renderCellHeader('scope', 'Scope', ['scope', 'role']);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('displays filter icon only for matching column when multiple filters applied', () => {
+ const { container } = renderCellHeader('organization', 'Organization', ['scope', 'organization', 'role']);
+
+ expect(screen.getByText('Organization')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('handles empty column title', () => {
+ const { container } = renderCellHeader('scope', '', ['scope']);
+
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('handles special characters in column title', () => {
+ const { container } = renderCellHeader('scope', 'Scope & Context', ['scope']);
+
+ expect(screen.getByText('Scope & Context')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('is case sensitive when matching column ID', () => {
+ const { container } = renderCellHeader('scope', 'Scope', ['SCOPE', 'Role']);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeInTheDocument();
+ });
+
+ it('handles long column titles with filters', () => {
+ const { container } = renderCellHeader('organization', 'Very Long Organization Column Title', ['organization']);
+
+ expect(screen.getByText('Very Long Organization Column Title')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('displays correct structure when filter is applied', () => {
+ renderCellHeader('role', 'Role', ['role']);
+
+ const container = screen.getByText('Role').closest('span');
+ expect(container).toHaveClass('d-flex', 'flex-row', 'align-items-center');
+ });
+ });
+
+ describe('getScopeManageAction', () => {
+ it('returns MANAGE_LIBRARY_TEAM for library scopes', () => {
+ expect(getScopeManageAction('lib:testorg:library-123')).toBe(
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM,
+ );
+ });
+
+ it('returns MANAGE_LIBRARY_TEAM for library scope with different format', () => {
+ expect(getScopeManageAction('lib:example:collection-456')).toBe(
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM,
+ );
+ });
+
+ it('returns MANAGE_COURSE_TEAM for course scopes', () => {
+ expect(getScopeManageAction('course-v1:testorg+course123+2023')).toBe(
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ );
+ });
+
+ it('returns MANAGE_COURSE_TEAM for course scope with different format', () => {
+ expect(getScopeManageAction('course-v1:example+math101+fall2023')).toBe(
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ );
+ });
+
+ it('returns default MANAGE_COURSE_TEAM for unknown scope types', () => {
+ expect(getScopeManageAction('unknown:scope:type')).toBe(
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ );
+ });
+
+ it('returns default MANAGE_COURSE_TEAM for empty scope', () => {
+ expect(getScopeManageAction('')).toBe(
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ );
+ });
+
+ it('handles scope that starts with "course" but not exactly "course-v1"', () => {
+ expect(getScopeManageAction('course:example:test')).toBe(
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ );
+ });
+
+ it('handles scope that starts with "lib" variations', () => {
+ expect(getScopeManageAction('library:test:scope')).toBe(
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM,
+ );
+ });
+ });
+
+ describe('getScopeManageActionPermission', () => {
+ it('returns correct permission object for library scope', () => {
+ const scope = 'lib:testorg:library-123';
+ const result = getScopeManageActionPermission(scope);
+
+ expect(result).toEqual({
+ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM,
+ scope,
+ });
+ });
+
+ it('returns correct permission object for course scope', () => {
+ const scope = 'course-v1:testorg+course123+2023';
+ const result = getScopeManageActionPermission(scope);
+
+ expect(result).toEqual({
+ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ scope,
+ });
+ });
+
+ it('returns correct permission object for unknown scope type', () => {
+ const scope = 'unknown:scope:type';
+ const result = getScopeManageActionPermission(scope);
+
+ expect(result).toEqual({
+ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ scope,
+ });
+ });
+
+ it('preserves original scope string in the returned object', () => {
+ const scope = 'lib:example:very-long-library-identifier-12345';
+ const result = getScopeManageActionPermission(scope);
+
+ expect(result.scope).toBe(scope);
+ expect(result.action).toBe(CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM);
+ });
+ });
+});
diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx
new file mode 100644
index 00000000..6d7282ea
--- /dev/null
+++ b/src/authz-module/utils.tsx
@@ -0,0 +1,34 @@
+import { Icon } from '@openedx/paragon';
+import { FilterList } from '@openedx/paragon/icons';
+import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './constants';
+
+export const getCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
+ if (filtersApplied.includes(columnId)) {
+ return (
+
+
+ {columnTitle}
+
+ );
+ }
+ return columnTitle;
+};
+
+export const getScopeManageAction = (scope: string) => {
+ if (scope.startsWith('lib')) {
+ return CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM;
+ }
+ if (scope.startsWith('course')) {
+ return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM;
+ }
+ // Default fallback or throw error for unknown scopes
+ return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM;
+};
+
+export const getScopeManageActionPermission = (scope: string) => {
+ const action = getScopeManageAction(scope);
+ return {
+ action,
+ scope,
+ };
+};
diff --git a/src/data/hooks.test.tsx b/src/data/hooks.test.tsx
index a2e284cc..e006989c 100644
--- a/src/data/hooks.test.tsx
+++ b/src/data/hooks.test.tsx
@@ -2,7 +2,7 @@ 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, useUserAccount } from './hooks';
+import { useValidateUserPermissions, useUserAccount, useValidateUserPermissionsNonSuspense } from './hooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
@@ -136,6 +136,128 @@ describe('useValidateUserPermissions', () => {
});
});
+describe('useValidateUserPermissionsNonSuspense', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns allowed true when permissions are valid', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }),
+ });
+
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense(permissions), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(getAuthenticatedHttpClient).toHaveBeenCalled();
+ expect(result.current.data[0].allowed).toBe(true);
+ });
+
+ it('returns allowed false when permissions are invalid', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({ data: mockInvalidPermissions }),
+ });
+
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense(permissions), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ 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 as jest.Mock).mockReturnValue({
+ post: jest.fn().mockRejectedValueOnce(mockError),
+ });
+
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense(permissions), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(result.current.error).toEqual(mockError);
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it('starts with loading state', () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockImplementation(() => new Promise(() => {})),
+ });
+
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense(permissions), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isPending).toBe(true);
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it('handles empty permissions array', async () => {
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense([]), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it('does not retry on failure', async () => {
+ const mockPost = jest.fn().mockRejectedValue(new Error('Network Error'));
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: mockPost,
+ });
+
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense(permissions), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles multiple permissions validation', async () => {
+ const multiplePermissions = [
+ {
+ action: 'act:read',
+ object: 'lib:test-lib',
+ scope: 'org:OpenedX',
+ },
+ {
+ action: 'act:write',
+ object: 'course:test-course',
+ scope: 'org:OpenedX',
+ },
+ ];
+
+ const multipleValidPermissions = [
+ { action: 'act:read', object: 'lib:test-lib', allowed: true },
+ { action: 'act:write', object: 'course:test-course', allowed: false },
+ ];
+
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValueOnce({ data: multipleValidPermissions }),
+ });
+
+ const { result } = renderHook(() => useValidateUserPermissionsNonSuspense(multiplePermissions), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toHaveLength(2);
+ expect(result.current.data[0].allowed).toBe(true);
+ expect(result.current.data[1].allowed).toBe(false);
+ });
+});
+
describe('useUserAccount', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/src/data/hooks.ts b/src/data/hooks.ts
index b6587c48..fa09f1bc 100644
--- a/src/data/hooks.ts
+++ b/src/data/hooks.ts
@@ -34,6 +34,31 @@ export const useValidateUserPermissions = (
retry: false,
});
+/**
+ * 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",
+ "object": "lib:test-lib",
+ "scope": "org:OpenedX"
+ }]);
+ * if (data[0].allowed) { ... }
+ *
+ */
+export const useValidateUserPermissionsNonSuspense = (
+ permissions: PermissionValidationRequest[],
+) => useQuery({
+ queryKey: adminConsoleQueryKeys.permissions(permissions),
+ queryFn: () => validateUserPermissions(permissions),
+ retry: false,
+});
+
export const useUserAccount = (username?: string) => useQuery({
queryKey: adminConsoleQueryKeys.userAccount(username),
queryFn: async () => getUserAccount(username),
diff --git a/src/types.ts b/src/types.ts
index decf55ea..93a85913 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -128,3 +128,7 @@ export type RoleToDelete = {
name?: string;
scope: string;
};
+
+export type UserRoleWithPermissions = UserRole & {
+ canManageScope?: boolean;
+};