Skip to content

Commit 8dc3139

Browse files
authored
feat: [FC-0099] allow deleting user's roles (#13)
Allows deleting users' roles from the users' roles view.
1 parent 50beaef commit 8dc3139

File tree

16 files changed

+934
-64
lines changed

16 files changed

+934
-64
lines changed

src/authz-module/components/RoleCard/index.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('RoleCard', () => {
2020
title: 'Admin',
2121
objectName: 'Test Library',
2222
description: 'Can manage everything',
23-
showDelete: true,
23+
handleDelete: jest.fn(),
2424
userCounter: 2,
2525
permissionsByResource: [
2626
{
@@ -56,7 +56,7 @@ describe('RoleCard', () => {
5656
expect(screen.getByText('Can manage everything')).toBeInTheDocument();
5757

5858
// Delete button
59-
expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument();
59+
expect(screen.getByRole('button', { name: /Delete role action/i })).toBeInTheDocument();
6060

6161
// Collapsible title
6262
expect(screen.getByText('Permissions')).toBeInTheDocument();
@@ -75,8 +75,8 @@ describe('RoleCard', () => {
7575
expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
7676
});
7777

78-
it('does not show delete button when showDelete is false', () => {
79-
renderWrapper(<RoleCard {...defaultProps} showDelete={false} />);
78+
it('does not show delete button when handleDelete is not passed', () => {
79+
renderWrapper(<RoleCard {...defaultProps} handleDelete={undefined} />);
8080
expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument();
8181
});
8282

src/authz-module/components/RoleCard/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface CardTitleProps {
1414
interface RoleCardProps extends CardTitleProps {
1515
objectName?: string | null;
1616
description: string;
17-
showDelete?: boolean;
17+
handleDelete?: () => void;
1818
permissionsByResource: any[];
1919
}
2020

@@ -31,7 +31,7 @@ const CardTitle = ({ title, userCounter = null }: CardTitleProps) => (
3131
);
3232

3333
const RoleCard = ({
34-
title, objectName, description, showDelete, permissionsByResource, userCounter,
34+
title, objectName, description, handleDelete, permissionsByResource, userCounter,
3535
}: RoleCardProps) => {
3636
const intl = useIntl();
3737

@@ -41,7 +41,9 @@ const RoleCard = ({
4141
title={<CardTitle title={title} userCounter={userCounter} />}
4242
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
4343
actions={
44-
showDelete && <IconButton variant="danger" alt="Delete role action" src={Delete} />
44+
handleDelete && (
45+
<IconButton variant="danger" onClick={handleDelete} alt={intl.formatMessage(messages['authz.role.card.delete.action.alt'])} src={Delete} />
46+
)
4547
}
4648
/>
4749
<Card.Section>

src/authz-module/components/RoleCard/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ const messages = defineMessages({
4646
defaultMessage: 'Reuse {resource}',
4747
description: 'Default label for the reuse action',
4848
},
49+
'authz.role.card.delete.action.alt': {
50+
id: 'authz.role.card.delete.action.alt',
51+
defaultMessage: 'Delete role action',
52+
description: 'Alt description for delete button',
53+
},
4954
});
5055

5156
export default messages;

src/authz-module/data/api.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ export interface GetTeamMembersResponse {
1717
count: number;
1818
}
1919

20+
export type RevokeUserRolesRequest = {
21+
users: string;
22+
role: string;
23+
scope: string;
24+
};
25+
26+
export interface DeleteRevokeUserRolesResponse {
27+
completed: {
28+
userIdentifiers: string;
29+
status: string;
30+
}[],
31+
errors: {
32+
userIdentifiers: string;
33+
error: string;
34+
}[],
35+
}
36+
2037
export type PermissionsByRole = {
2138
role: string;
2239
permissions: string[];
@@ -77,3 +94,16 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
7794
const { data } = await getAuthenticatedHttpClient().get(url);
7895
return camelCaseObject(data.results);
7996
};
97+
98+
export const revokeUserRoles = async (
99+
data: RevokeUserRolesRequest,
100+
): Promise<DeleteRevokeUserRolesResponse> => {
101+
const url = new URL(getApiUrl('/api/authz/v1/roles/users/'));
102+
url.searchParams.append('users', data.users);
103+
url.searchParams.append('role', data.role);
104+
url.searchParams.append('scope', data.scope);
105+
106+
// If this is not transformed to string, it shows a 404 with the token CSRF acquisition request
107+
const res = await getAuthenticatedHttpClient().delete(url.toString());
108+
return camelCaseObject(res.data);
109+
};

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

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
55
import {
6-
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole,
6+
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
77
} from './hooks';
88

99
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -240,3 +240,103 @@ describe('usePermissionsByRole', () => {
240240
});
241241
});
242242
});
243+
244+
describe('useRevokeUserRoles', () => {
245+
beforeEach(() => {
246+
jest.clearAllMocks();
247+
});
248+
249+
it('successfully revokes user roles', async () => {
250+
const mockResponse = {
251+
completed: [
252+
{
253+
userIdentifiers: 'jdoe',
254+
status: 'role_removed',
255+
},
256+
],
257+
errors: [],
258+
};
259+
260+
getAuthenticatedHttpClient.mockReturnValue({
261+
delete: jest.fn().mockResolvedValue({ data: mockResponse }),
262+
});
263+
264+
const { result } = renderHook(() => useRevokeUserRoles(), {
265+
wrapper: createWrapper(),
266+
});
267+
268+
const revokeRoleData = {
269+
scope: 'lib:123',
270+
users: 'jdoe',
271+
role: 'author',
272+
};
273+
274+
await act(async () => {
275+
result.current.mutate({ data: revokeRoleData });
276+
});
277+
278+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
279+
280+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
281+
expect(result.current.data).toEqual(mockResponse);
282+
});
283+
284+
it('handles error when revoking roles fails', async () => {
285+
getAuthenticatedHttpClient.mockReturnValue({
286+
delete: jest.fn().mockRejectedValue(new Error('Failed to revoke roles')),
287+
});
288+
289+
const { result } = renderHook(() => useRevokeUserRoles(), {
290+
wrapper: createWrapper(),
291+
});
292+
293+
const revokeRoleData = {
294+
scope: 'lib:123',
295+
users: 'jdoe',
296+
role: 'author',
297+
};
298+
299+
await act(async () => {
300+
result.current.mutate({ data: revokeRoleData });
301+
});
302+
303+
await waitFor(() => expect(result.current.isError).toBe(true));
304+
305+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
306+
expect(result.current.error).toEqual(new Error('Failed to revoke roles'));
307+
});
308+
309+
it('constructs URL with correct query parameters', async () => {
310+
const mockDelete = jest.fn().mockResolvedValue({
311+
data: { completed: [], errors: [] },
312+
});
313+
314+
getAuthenticatedHttpClient.mockReturnValue({
315+
delete: mockDelete,
316+
});
317+
318+
const { result } = renderHook(() => useRevokeUserRoles(), {
319+
wrapper: createWrapper(),
320+
});
321+
322+
const revokeRoleData = {
323+
scope: 'lib:org/test-lib',
324+
users: 'user1@example.com',
325+
role: 'instructor',
326+
};
327+
328+
await act(async () => {
329+
result.current.mutate({ data: revokeRoleData });
330+
});
331+
332+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
333+
334+
expect(mockDelete).toHaveBeenCalled();
335+
const calledUrl = new URL(mockDelete.mock.calls[0][0]);
336+
337+
// Verify the URL contains the correct query parameters
338+
expect(calledUrl.searchParams.get('users')).toBe(revokeRoleData.users);
339+
expect(calledUrl.searchParams.get('role')).toBe(revokeRoleData.role);
340+
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
341+
});
342+
});

src/authz-module/data/hooks.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { appId } from '@src/constants';
55
import { LibraryMetadata } from '@src/types';
66
import {
77
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
8-
GetTeamMembersResponse, PermissionsByRole, QuerySettings,
8+
GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
99
} from './api';
1010

1111
const authzQueryKeys = {
@@ -87,3 +87,22 @@ export const useAssignTeamMembersRole = () => {
8787
},
8888
});
8989
};
90+
91+
/**
92+
* React Query hook to remove roles for a specific team member within a scope.
93+
*
94+
* @example
95+
* const { mutate: revokeUserRoles } = useRevokeUserRoles();
96+
* revokeUserRoles({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } });
97+
*/
98+
export const useRevokeUserRoles = () => {
99+
const queryClient = useQueryClient();
100+
return useMutation({
101+
mutationFn: async ({ data }: {
102+
data: RevokeUserRolesRequest
103+
}) => revokeUserRoles(data),
104+
onSettled: (_data, _error, { data: { scope } }) => {
105+
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
106+
},
107+
});
108+
};

src/authz-module/index.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,10 @@
4242
}
4343
}
4444

45-
4645
.toast-container {
4746
// Ensure toast appears above modal
4847
z-index: 1000;
4948
// Move toast to the right
5049
left: auto;
5150
right: var(--pgn-spacing-toast-container-gutter-lg);
52-
}
51+
}

src/authz-module/index.test.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
33
import { MemoryRouter, Outlet } from 'react-router-dom';
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
55
import { initializeMockApp } from '@edx/frontend-platform/testing';
6+
import { IntlProvider } from '@edx/frontend-platform/i18n';
67
import AuthZModule from './index';
78

89
jest.mock('./libraries-manager', () => ({
@@ -32,11 +33,13 @@ describe('AuthZModule', () => {
3233
const path = '/libraries/lib:123';
3334

3435
render(
35-
<QueryClientProvider client={queryClient}>
36-
<MemoryRouter initialEntries={[path]}>
37-
<AuthZModule />
38-
</MemoryRouter>
39-
</QueryClientProvider>,
36+
<IntlProvider locale="en">
37+
<QueryClientProvider client={queryClient}>
38+
<MemoryRouter initialEntries={[path]}>
39+
<AuthZModule />
40+
</MemoryRouter>
41+
</QueryClientProvider>
42+
</IntlProvider>,
4043
);
4144

4245
expect(screen.getByTestId('loading-page')).toBeInTheDocument();
@@ -51,11 +54,13 @@ describe('AuthZModule', () => {
5154
const path = '/libraries/lib:123/testuser';
5255

5356
render(
54-
<QueryClientProvider client={queryClient}>
55-
<MemoryRouter initialEntries={[path]}>
56-
<AuthZModule />
57-
</MemoryRouter>
58-
</QueryClientProvider>,
57+
<IntlProvider locale="en">
58+
<QueryClientProvider client={queryClient}>
59+
<MemoryRouter initialEntries={[path]}>
60+
<AuthZModule />
61+
</MemoryRouter>
62+
</QueryClientProvider>
63+
</IntlProvider>,
5964
);
6065
await waitFor(() => {
6166
expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument();

src/authz-module/index.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary';
44
import { QueryErrorResetBoundary } from '@tanstack/react-query';
55
import LoadingPage from '@src/components/LoadingPage';
66
import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage';
7+
import { ToastManagerProvider } from './libraries-manager/ToastManagerContext';
78
import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager';
89
import { ROUTES } from './constants';
910

@@ -13,14 +14,16 @@ const AuthZModule = () => (
1314
<QueryErrorResetBoundary>
1415
{({ reset }) => (
1516
<ErrorBoundary fallbackRender={LibrariesErrorFallback} onReset={reset}>
16-
<Suspense fallback={<LoadingPage />}>
17-
<Routes>
18-
<Route element={<LibrariesLayout />}>
19-
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
20-
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
21-
</Route>
22-
</Routes>
23-
</Suspense>
17+
<ToastManagerProvider>
18+
<Suspense fallback={<LoadingPage />}>
19+
<Routes>
20+
<Route element={<LibrariesLayout />}>
21+
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
22+
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
23+
</Route>
24+
</Routes>
25+
</Suspense>
26+
</ToastManagerProvider>
2427
</ErrorBoundary>
2528
)}
2629
</QueryErrorResetBoundary>

0 commit comments

Comments
 (0)