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; +};