Skip to content

Commit 3b8d2d4

Browse files
fix: hide remove role button for users without permissions
1 parent 2b3b480 commit 3b8d2d4

5 files changed

Lines changed: 142 additions & 11 deletions

File tree

src/authz-module/audit-user/index.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ jest.mock('@edx/frontend-component-header', () => ({
3030
jest.mock('@src/data/hooks', () => ({
3131
...jest.requireActual('@src/data/hooks'),
3232
useUserAccount: jest.fn(),
33+
useValidateUserPermissions: jest.fn().mockReturnValue({
34+
data: [{ allowed: true }],
35+
isLoading: false,
36+
}),
3337
}));
3438

3539
// Mock the useRevokeUserRoles hook

src/authz-module/components/TableCells.test.tsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
33
import { renderWrapper } from '@src/setupTest';
44
import userEvent from '@testing-library/user-event';
55
import { DataTableContext } from '@openedx/paragon';
6+
import { useValidateUserPermissions } from '@src/data/hooks';
67
import {
78
NameCell,
89
ViewActionCell,
@@ -14,9 +15,9 @@ import {
1415
createActionsCell,
1516
} from './TableCells';
1617

17-
// TODO: remove console.log mocks and implement actual logic for these cells, then update tests accordingly
18-
// Mock console.log for TODO functions
19-
jest.spyOn(console, 'log').mockImplementation(() => {});
18+
jest.mock('@src/data/hooks', () => ({
19+
useValidateUserPermissions: jest.fn(),
20+
}));
2021

2122
const mockNavigate = jest.fn();
2223

@@ -494,6 +495,12 @@ describe('TableCells Components', () => {
494495
};
495496

496497
beforeEach(() => {
498+
(useValidateUserPermissions as jest.Mock).mockReturnValue({
499+
isLoading: false,
500+
data: [
501+
{ allowed: true },
502+
],
503+
});
497504
jest.clearAllMocks();
498505
});
499506

@@ -552,6 +559,40 @@ describe('TableCells Components', () => {
552559
await user.hover(infoIcon);
553560
expect(screen.getByText(/Please go to Django Admin to manage it/i)).toBeInTheDocument();
554561
});
562+
563+
it('renders a disabled button when user does not have permission', async () => {
564+
(useValidateUserPermissions as jest.Mock).mockReturnValue({
565+
isLoading: false,
566+
data: [
567+
{ allowed: false },
568+
],
569+
});
570+
const CustomActionsCell = createActionsCell({
571+
onClickDeleteButton: mockOnClickDeleteButton,
572+
isUserAuthenticatedPage: false,
573+
});
574+
renderWrapper(<CustomActionsCell row={baseRow} column={{ id: 'actions' }} />);
575+
576+
const deleteButton = screen.queryByRole('button', { name: /delete role action/i });
577+
expect(deleteButton).toBeDisabled();
578+
});
579+
580+
it('renders no button when permissions is loading', async () => {
581+
(useValidateUserPermissions as jest.Mock).mockReturnValue({
582+
data: [
583+
{ allowed: false },
584+
],
585+
isLoading: true,
586+
});
587+
const CustomActionsCell = createActionsCell({
588+
onClickDeleteButton: mockOnClickDeleteButton,
589+
isUserAuthenticatedPage: false,
590+
});
591+
renderWrapper(<CustomActionsCell row={baseRow} column={{ id: 'actions' }} />);
592+
593+
const deleteButton = screen.queryByRole('button', { name: /delete role action/i });
594+
expect(deleteButton).not.toBeInTheDocument();
595+
});
555596
});
556597

557598
describe('ViewAllPermissionsCell', () => {

src/authz-module/components/TableCells.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import {
1010
} from '@src/types';
1111
import { useNavigate } from 'react-router-dom';
1212
import { useContext, useMemo } from 'react';
13-
import { ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants';
13+
import {
14+
ADMIN_ROLES, CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL,
15+
} from '@src/authz-module/constants';
1416
import {
1517
Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext,
1618
} from '@openedx/paragon';
19+
import { useValidateUserPermissions } from '@src/data/hooks';
1720
import { RESOURCE_ICONS } from './constants';
1821
import messages from './messages';
1922
import ViewMoreLink from './ViewMoreLink';
@@ -162,7 +165,13 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => {
162165

163166
const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: ActionsCellProps) => {
164167
const { formatMessage } = useIntl();
165-
const { role } = row.original;
168+
const { role, scope } = row.original;
169+
const action = role.startsWith('lib') ? CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM : CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM;
170+
const permission = {
171+
action,
172+
scope,
173+
};
174+
const { data: hasPermission, isLoading } = useValidateUserPermissions([permission]);
166175

167176
const handleDelete = () => {
168177
const roleToDelete = {
@@ -204,8 +213,10 @@ const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: Acti
204213
);
205214
}
206215

216+
if (isLoading) { return null; }
217+
207218
return (
208-
<IconButton variant="danger" onClick={handleDelete} alt={formatMessage(messages['authz.user.table.delete.action.alt'])} src={Delete} />
219+
<IconButton disabled={!hasPermission[0]?.allowed} variant="danger" onClick={handleDelete} alt={formatMessage(messages['authz.user.table.delete.action.alt'])} src={Delete} />
209220
);
210221
};
211222

src/authz-module/data/hooks.test.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,81 @@ describe('useRevokeUserRoles', () => {
687687
expect(calledUrl.searchParams.get('role')).toBe(revokeRoleData.role);
688688
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
689689
});
690+
691+
it('invalidates userRoles queries on mutation completion', async () => {
692+
const mockResponse = {
693+
completed: [
694+
{
695+
userIdentifiers: 'jdoe',
696+
status: 'role_removed',
697+
},
698+
],
699+
errors: [],
700+
};
701+
702+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
703+
delete: jest.fn().mockResolvedValue({ data: mockResponse }),
704+
});
705+
706+
const queryClient = new QueryClient({
707+
defaultOptions: {
708+
queries: { retry: false },
709+
mutations: { retry: false },
710+
},
711+
});
712+
713+
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
714+
715+
const wrapper = ({ children }: { children: ReactNode }) => (
716+
<QueryClientProvider client={queryClient}>
717+
{children}
718+
</QueryClientProvider>
719+
);
720+
721+
const { result } = renderHook(() => useRevokeUserRoles(), {
722+
wrapper,
723+
});
724+
725+
const revokeRoleData = {
726+
scope: 'lib:123',
727+
users: 'jdoe',
728+
role: 'author',
729+
};
730+
731+
await act(async () => {
732+
result.current.mutate({ data: revokeRoleData });
733+
});
734+
735+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
736+
737+
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
738+
predicate: expect.any(Function),
739+
});
740+
741+
const userRolesCalls = invalidateQueriesSpy.mock.calls.filter(call => {
742+
const filters = call[0];
743+
return filters && typeof (filters as any).predicate === 'function';
744+
});
745+
746+
const userRolesCall = userRolesCalls.find(call => {
747+
const filters = call[0];
748+
const predicate = filters && (filters as any).predicate;
749+
return typeof predicate === 'function' && predicate({ queryKey: ['test-app', 'authz', 'userRoles', 'testuser'] });
750+
});
751+
752+
expect(userRolesCall).toBeDefined();
753+
754+
let predicate;
755+
if (userRolesCall) {
756+
const filters = userRolesCall[0];
757+
predicate = filters && (filters as any).predicate;
758+
}
759+
expect(typeof predicate).toBe('function');
760+
expect(predicate && predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(true);
761+
expect(predicate && predicate({ queryKey: ['test-app', 'authz', 'teamMembers'] })).toBe(false);
762+
763+
invalidateQueriesSpy.mockRestore();
764+
});
690765
});
691766

692767
describe('useAllRoleAssignments', () => {

src/authz-module/data/hooks.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
} from '@tanstack/react-query';
44
import { appId } from '@src/constants';
55
import { LibraryMetadata } from '@src/types';
6-
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
76
import {
87
assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments,
98
GetAllRoleAssignmentsResponse, getLibrary, getOrgs, GetOrgsResponse,
@@ -129,18 +128,19 @@ export const useValidateUsers = () => useMutation({
129128
*/
130129
export const useRevokeUserRoles = () => {
131130
const queryClient = useQueryClient();
132-
const { querySettings: defaultQuerySettings } = useQuerySettings();
133131
return useMutation({
134132
mutationFn: async ({ data }: {
135133
data: RevokeUserRolesRequest
136134
}) => revokeUserRoles(data),
137-
onSettled: (_data, _error, { data: { scope, users, querySettings } }) => {
135+
onSettled: (_data, _error, { data: { scope } }) => {
138136
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
139137
queryClient.invalidateQueries({ queryKey: authzQueryKeys.permissionsByRole(scope) });
140138
queryClient.invalidateQueries({
141-
queryKey: authzQueryKeys.userRoles(users, querySettings),
139+
predicate: (query) => query.queryKey.includes('userRoles'),
140+
});
141+
queryClient.invalidateQueries({
142+
predicate: (query) => query.queryKey.includes('allRoleAssignments'),
142143
});
143-
queryClient.invalidateQueries({ queryKey: authzQueryKeys.allRoleAssignments(defaultQuerySettings) });
144144
},
145145
});
146146
};

0 commit comments

Comments
 (0)