Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/authz-module/components/RoleCard/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('RoleCard', () => {
title: 'Admin',
objectName: 'Test Library',
description: 'Can manage everything',
showDelete: true,
handleDelete: jest.fn(),
userCounter: 2,
permissionsByResource: [
{
Expand Down Expand Up @@ -56,7 +56,7 @@ describe('RoleCard', () => {
expect(screen.getByText('Can manage everything')).toBeInTheDocument();

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

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

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

Expand Down
8 changes: 5 additions & 3 deletions src/authz-module/components/RoleCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface CardTitleProps {
interface RoleCardProps extends CardTitleProps {
objectName?: string | null;
description: string;
showDelete?: boolean;
handleDelete?: () => void;
permissionsByResource: any[];
}

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

const RoleCard = ({
title, objectName, description, showDelete, permissionsByResource, userCounter,
title, objectName, description, handleDelete, permissionsByResource, userCounter,
}: RoleCardProps) => {
const intl = useIntl();

Expand All @@ -41,7 +41,9 @@ const RoleCard = ({
title={<CardTitle title={title} userCounter={userCounter} />}
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
actions={
showDelete && <IconButton variant="danger" alt="Delete role action" src={Delete} />
handleDelete && (
<IconButton variant="danger" onClick={handleDelete} alt={intl.formatMessage(messages['authz.role.card.delete.action.alt'])} src={Delete} />
)
}
/>
<Card.Section>
Expand Down
5 changes: 5 additions & 0 deletions src/authz-module/components/RoleCard/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ const messages = defineMessages({
defaultMessage: 'Reuse {resource}',
description: 'Default label for the reuse action',
},
'authz.role.card.delete.action.alt': {
id: 'authz.role.card.delete.action.alt',
defaultMessage: 'Delete role action',
description: 'Alt description for delete button',
},
});

export default messages;
30 changes: 30 additions & 0 deletions src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ export interface GetTeamMembersResponse {
count: number;
}

export type RevokeUserRolesRequest = {
users: string;
role: string;
scope: string;
};

export interface DeleteRevokeUserRolesResponse {
completed: {
userIdentifiers: string;
status: string;
}[],
errors: {
userIdentifiers: string;
error: string;
}[],
}

export type PermissionsByRole = {
role: string;
permissions: string[];
Expand Down Expand Up @@ -77,3 +94,16 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data.results);
};

export const revokeUserRoles = async (
data: RevokeUserRolesRequest,
): Promise<DeleteRevokeUserRolesResponse> => {
const url = new URL(getApiUrl('/api/authz/v1/roles/users/'));
url.searchParams.append('users', data.users);
url.searchParams.append('role', data.role);
url.searchParams.append('scope', data.scope);

// If this is not transformed to string, it shows a 404 with the token CSRF acquisition request
const res = await getAuthenticatedHttpClient().delete(url.toString());
return camelCaseObject(res.data);
};
102 changes: 101 additions & 1 deletion src/authz-module/data/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole,
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
} from './hooks';

jest.mock('@edx/frontend-platform/auth', () => ({
Expand Down Expand Up @@ -240,3 +240,103 @@ describe('usePermissionsByRole', () => {
});
});
});

describe('useRevokeUserRoles', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('successfully revokes user roles', async () => {
const mockResponse = {
completed: [
{
userIdentifiers: 'jdoe',
status: 'role_removed',
},
],
errors: [],
};

getAuthenticatedHttpClient.mockReturnValue({
delete: jest.fn().mockResolvedValue({ data: mockResponse }),
});

const { result } = renderHook(() => useRevokeUserRoles(), {
wrapper: createWrapper(),
});

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(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data).toEqual(mockResponse);
});

it('handles error when revoking roles fails', async () => {
getAuthenticatedHttpClient.mockReturnValue({
delete: jest.fn().mockRejectedValue(new Error('Failed to revoke roles')),
});

const { result } = renderHook(() => useRevokeUserRoles(), {
wrapper: createWrapper(),
});

const revokeRoleData = {
scope: 'lib:123',
users: 'jdoe',
role: 'author',
};

await act(async () => {
result.current.mutate({ data: revokeRoleData });
});

await waitFor(() => expect(result.current.isError).toBe(true));

expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.error).toEqual(new Error('Failed to revoke roles'));
});

it('constructs URL with correct query parameters', async () => {
const mockDelete = jest.fn().mockResolvedValue({
data: { completed: [], errors: [] },
});

getAuthenticatedHttpClient.mockReturnValue({
delete: mockDelete,
});

const { result } = renderHook(() => useRevokeUserRoles(), {
wrapper: createWrapper(),
});

const revokeRoleData = {
scope: 'lib:org/test-lib',
users: 'user1@example.com',
role: 'instructor',
};

await act(async () => {
result.current.mutate({ data: revokeRoleData });
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(mockDelete).toHaveBeenCalled();
const calledUrl = new URL(mockDelete.mock.calls[0][0]);

// Verify the URL contains the correct query parameters
expect(calledUrl.searchParams.get('users')).toBe(revokeRoleData.users);
expect(calledUrl.searchParams.get('role')).toBe(revokeRoleData.role);
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
});
});
21 changes: 20 additions & 1 deletion src/authz-module/data/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { appId } from '@src/constants';
import { LibraryMetadata } from '@src/types';
import {
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
GetTeamMembersResponse, PermissionsByRole, QuerySettings,
GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
} from './api';

const authzQueryKeys = {
Expand Down Expand Up @@ -87,3 +87,22 @@ export const useAssignTeamMembersRole = () => {
},
});
};

/**
* React Query hook to remove roles for a specific team member within a scope.
*
* @example
* const { mutate: revokeUserRoles } = useRevokeUserRoles();
* revokeUserRoles({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } });
*/
export const useRevokeUserRoles = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ data }: {
data: RevokeUserRolesRequest
}) => revokeUserRoles(data),
onSettled: (_data, _error, { data: { scope } }) => {
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) });
},
});
};
3 changes: 1 addition & 2 deletions src/authz-module/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,10 @@
}
}


.toast-container {
// Ensure toast appears above modal
z-index: 1000;
// Move toast to the right
left: auto;
right: var(--pgn-spacing-toast-container-gutter-lg);
}
}
25 changes: 15 additions & 10 deletions src/authz-module/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Outlet } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import AuthZModule from './index';

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

render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<AuthZModule />
</MemoryRouter>
</QueryClientProvider>,
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<AuthZModule />
</MemoryRouter>
</QueryClientProvider>
</IntlProvider>,
);

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

render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<AuthZModule />
</MemoryRouter>
</QueryClientProvider>,
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<AuthZModule />
</MemoryRouter>
</QueryClientProvider>
</IntlProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument();
Expand Down
19 changes: 11 additions & 8 deletions src/authz-module/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import LoadingPage from '@src/components/LoadingPage';
import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage';
import { ToastManagerProvider } from './libraries-manager/ToastManagerContext';
import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager';
import { ROUTES } from './constants';

Expand All @@ -13,14 +14,16 @@ const AuthZModule = () => (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary fallbackRender={LibrariesErrorFallback} onReset={reset}>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route element={<LibrariesLayout />}>
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
</Route>
</Routes>
</Suspense>
<ToastManagerProvider>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route element={<LibrariesLayout />}>
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
</Route>
</Routes>
</Suspense>
</ToastManagerProvider>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
Expand Down
Loading