Skip to content

Commit df73f14

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

11 files changed

Lines changed: 361 additions & 116 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/audit-user/index.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import {
1010
Container, DataTable,
1111
} from '@openedx/paragon';
1212
import TableFooter from '@src/authz-module/components/TableFooter/TableFooter';
13-
import { AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
13+
import {
14+
AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE,
15+
} from '@src/authz-module/constants';
1416
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
1517
import { useNavigate, useParams } from 'react-router-dom';
16-
import { useUserAccount } from '@src/data/hooks';
18+
import { useUserAccount, useValidateUserPermissions } from '@src/data/hooks';
1719
import baseMessages from '@src/authz-module/messages';
1820
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
1921
import {
@@ -30,7 +32,7 @@ import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilte
3032
import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar';
3133
import messages from './messages';
3234
import ConfirmDeletionModal from '../components/ConfirmDeletionModal';
33-
import { getCellHeader } from '../components/utils';
35+
import { getCellHeader, getScopeManageActionPermission } from '../utils';
3436

3537
const AuditUserPage = () => {
3638
const { formatMessage } = useIntl();
@@ -52,6 +54,13 @@ const AuditUserPage = () => {
5254
} = useToastManager();
5355
const { mutate: revokeUserRoles, isPending: isRevokingUserRolePending } = useRevokeUserRoles();
5456

57+
const deletePermissions = useMemo(() => {
58+
const uniqueScopes = [...new Set(userAssignments.map(assignment => assignment.scope))];
59+
return uniqueScopes.map(scope => getScopeManageActionPermission(scope));
60+
}, [userAssignments]);
61+
62+
const { data: permissionsToManageScope } = useValidateUserPermissions(deletePermissions);
63+
5564
const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]);
5665

5766
useEffect(() => {
@@ -72,6 +81,11 @@ const AuditUserPage = () => {
7281
setShowConfirmDeletionModal(true);
7382
}, [isRevokingUserRolePending]);
7483

84+
const hasPermissionToDeleteScope = useCallback((scope: string) => {
85+
const permissionIndex = deletePermissions.findIndex(permission => permission.scope === scope);
86+
return permissionsToManageScope?.[permissionIndex]?.allowed;
87+
}, [deletePermissions, permissionsToManageScope]);
88+
7589
const navLinks = useMemo(() => [
7690
{
7791
label: formatMessage(baseMessages['authz.management.home.nav.link']),
@@ -91,9 +105,10 @@ const AuditUserPage = () => {
91105
Cell: createActionsCell({
92106
onClickDeleteButton: handleShowConfirmDeletionModal,
93107
isUserAuthenticatedPage: username === authenticatedUser.username,
108+
hasPermissionToDeleteScope,
94109
}),
95110
},
96-
], [authenticatedUser.username, formatMessage, handleShowConfirmDeletionModal, username]);
111+
], [authenticatedUser.username, formatMessage, handleShowConfirmDeletionModal, hasPermissionToDeleteScope, username]);
97112

98113
const columns = useMemo(() => [
99114
{

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ import {
1414
createActionsCell,
1515
} from './TableCells';
1616

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(() => {});
20-
2117
const mockNavigate = jest.fn();
2218

2319
jest.mock('react-router-dom', () => ({
@@ -501,6 +497,7 @@ describe('TableCells Components', () => {
501497
const user = userEvent.setup();
502498
const CustomActionsCell = createActionsCell({
503499
onClickDeleteButton: mockOnClickDeleteButton,
500+
hasPermissionToDeleteScope: () => true,
504501
isUserAuthenticatedPage: false,
505502
});
506503
renderWrapper(<CustomActionsCell row={baseRow} column={{ id: 'actions' }} />);
@@ -524,6 +521,7 @@ describe('TableCells Components', () => {
524521
const CustomActionsCell = createActionsCell({
525522
onClickDeleteButton: mockOnClickDeleteButton,
526523
isUserAuthenticatedPage: true,
524+
hasPermissionToDeleteScope: () => true,
527525
});
528526
renderWrapper(<CustomActionsCell row={adminRow} column={{ id: 'actions' }} />);
529527

@@ -543,6 +541,7 @@ describe('TableCells Components', () => {
543541
const user = userEvent.setup();
544542
const CustomActionsCell = createActionsCell({
545543
onClickDeleteButton: mockOnClickDeleteButton,
544+
hasPermissionToDeleteScope: () => true,
546545
isUserAuthenticatedPage: true,
547546
});
548547
renderWrapper(<CustomActionsCell row={djangoRow} column={{ id: 'actions' }} />);
@@ -552,6 +551,18 @@ describe('TableCells Components', () => {
552551
await user.hover(infoIcon);
553552
expect(screen.getByText(/Please go to Django Admin to manage it/i)).toBeInTheDocument();
554553
});
554+
555+
it('renders a disabled button when user does not have permission', async () => {
556+
const CustomActionsCell = createActionsCell({
557+
onClickDeleteButton: mockOnClickDeleteButton,
558+
isUserAuthenticatedPage: false,
559+
hasPermissionToDeleteScope: () => false,
560+
});
561+
renderWrapper(<CustomActionsCell row={baseRow} column={{ id: 'actions' }} />);
562+
563+
const deleteButton = screen.queryByRole('button', { name: /delete role action/i });
564+
expect(deleteButton).toBeDisabled();
565+
});
555566
});
556567

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

src/authz-module/components/TableCells.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ 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, 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';
@@ -37,6 +39,7 @@ type ExtendedCellProps = CellPropsWithValue & {
3739

3840
type ActionsCellExtraProps = {
3941
onClickDeleteButton: (role: RoleToDelete) => void;
42+
hasPermissionToDeleteScope: (scope: string) => boolean;
4043
isUserAuthenticatedPage: boolean;
4144
};
4245

@@ -160,9 +163,13 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => {
160163
);
161164
};
162165

163-
const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: ActionsCellProps) => {
166+
const ActionsCell = ({
167+
row, onClickDeleteButton, isUserAuthenticatedPage, hasPermissionToDeleteScope,
168+
}: ActionsCellProps) => {
164169
const { formatMessage } = useIntl();
165-
const { role } = row.original;
170+
const { role, scope } = row.original;
171+
172+
const hasPermissionsToDelete = useMemo(() => hasPermissionToDeleteScope(scope), [hasPermissionToDeleteScope, scope]);
166173

167174
const handleDelete = () => {
168175
const roleToDelete = {
@@ -205,7 +212,13 @@ const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: Acti
205212
}
206213

207214
return (
208-
<IconButton variant="danger" onClick={handleDelete} alt={formatMessage(messages['authz.user.table.delete.action.alt'])} src={Delete} />
215+
<IconButton
216+
disabled={!hasPermissionsToDelete}
217+
variant={hasPermissionsToDelete ? 'danger' : 'light'}
218+
onClick={handleDelete}
219+
alt={formatMessage(messages['authz.user.table.delete.action.alt'])}
220+
src={Delete}
221+
/>
209222
);
210223
};
211224

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

Lines changed: 0 additions & 84 deletions
This file was deleted.

src/authz-module/components/utils.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,95 @@ 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+
// Type workaround for SpyInstance type not matching function signature
742+
type MockCall = Parameters<any>[0];
743+
type PredicateCall = MockCall & { predicate: (query: { queryKey: readonly unknown[] }) => boolean };
744+
745+
const predicateCalls = invalidateQueriesSpy.mock.calls
746+
.map(call => call[0])
747+
.filter((call): call is PredicateCall => call !== undefined
748+
&& 'predicate' in call
749+
&& typeof call.predicate === 'function');
750+
751+
const userRolesCall = predicateCalls.find(call => {
752+
const { predicate } = call;
753+
return predicate({ queryKey: ['test-app', 'authz', 'userRoles', 'testuser'] });
754+
});
755+
756+
expect(userRolesCall).toBeDefined();
757+
758+
if (userRolesCall) {
759+
const { predicate } = userRolesCall;
760+
expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(true);
761+
expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembers'] })).toBe(false);
762+
}
763+
764+
const allRoleAssignmentsCall = predicateCalls.find(call => {
765+
const { predicate } = call;
766+
return predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments', {}] });
767+
});
768+
769+
expect(allRoleAssignmentsCall).toBeDefined();
770+
771+
if (allRoleAssignmentsCall) {
772+
const { predicate } = allRoleAssignmentsCall;
773+
expect(predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments'] })).toBe(true);
774+
expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(false);
775+
}
776+
777+
invalidateQueriesSpy.mockRestore();
778+
});
690779
});
691780

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

0 commit comments

Comments
 (0)