feat: [FC-0099] allow deleting user's roles (#13)

Allows deleting users' roles from the users' roles view.
This commit is contained in:
Brayan Cerón
2025-10-24 11:03:02 -05:00
committed by GitHub
parent 50beaef35d
commit 8dc3139ef9
16 changed files with 934 additions and 64 deletions

View File

@@ -20,7 +20,7 @@ describe('RoleCard', () => {
title: 'Admin', title: 'Admin',
objectName: 'Test Library', objectName: 'Test Library',
description: 'Can manage everything', description: 'Can manage everything',
showDelete: true, handleDelete: jest.fn(),
userCounter: 2, userCounter: 2,
permissionsByResource: [ permissionsByResource: [
{ {
@@ -56,7 +56,7 @@ describe('RoleCard', () => {
expect(screen.getByText('Can manage everything')).toBeInTheDocument(); expect(screen.getByText('Can manage everything')).toBeInTheDocument();
// Delete button // Delete button
expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Delete role action/i })).toBeInTheDocument();
// Collapsible title // Collapsible title
expect(screen.getByText('Permissions')).toBeInTheDocument(); expect(screen.getByText('Permissions')).toBeInTheDocument();
@@ -75,8 +75,8 @@ describe('RoleCard', () => {
expect(screen.getByTestId('manage-icon')).toBeInTheDocument(); expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
}); });
it('does not show delete button when showDelete is false', () => { it('does not show delete button when handleDelete is not passed', () => {
renderWrapper(<RoleCard {...defaultProps} showDelete={false} />); renderWrapper(<RoleCard {...defaultProps} handleDelete={undefined} />);
expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument();
}); });

View File

@@ -14,7 +14,7 @@ interface CardTitleProps {
interface RoleCardProps extends CardTitleProps { interface RoleCardProps extends CardTitleProps {
objectName?: string | null; objectName?: string | null;
description: string; description: string;
showDelete?: boolean; handleDelete?: () => void;
permissionsByResource: any[]; permissionsByResource: any[];
} }
@@ -31,7 +31,7 @@ const CardTitle = ({ title, userCounter = null }: CardTitleProps) => (
); );
const RoleCard = ({ const RoleCard = ({
title, objectName, description, showDelete, permissionsByResource, userCounter, title, objectName, description, handleDelete, permissionsByResource, userCounter,
}: RoleCardProps) => { }: RoleCardProps) => {
const intl = useIntl(); const intl = useIntl();
@@ -41,7 +41,9 @@ const RoleCard = ({
title={<CardTitle title={title} userCounter={userCounter} />} title={<CardTitle title={title} userCounter={userCounter} />}
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''} subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
actions={ 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> <Card.Section>

View File

@@ -46,6 +46,11 @@ const messages = defineMessages({
defaultMessage: 'Reuse {resource}', defaultMessage: 'Reuse {resource}',
description: 'Default label for the reuse action', 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; export default messages;

View File

@@ -17,6 +17,23 @@ export interface GetTeamMembersResponse {
count: number; 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 = { export type PermissionsByRole = {
role: string; role: string;
permissions: string[]; permissions: string[];
@@ -77,3 +94,16 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data.results); 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);
};

View File

@@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { import {
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
} from './hooks'; } from './hooks';
jest.mock('@edx/frontend-platform/auth', () => ({ jest.mock('@edx/frontend-platform/auth', () => ({
@@ -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);
});
});

View File

@@ -5,7 +5,7 @@ import { appId } from '@src/constants';
import { LibraryMetadata } from '@src/types'; import { LibraryMetadata } from '@src/types';
import { import {
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
GetTeamMembersResponse, PermissionsByRole, QuerySettings, GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
} from './api'; } from './api';
const authzQueryKeys = { const authzQueryKeys = {
@@ -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) });
},
});
};

View File

@@ -42,11 +42,10 @@
} }
} }
.toast-container { .toast-container {
// Ensure toast appears above modal // Ensure toast appears above modal
z-index: 1000; z-index: 1000;
// Move toast to the right // Move toast to the right
left: auto; left: auto;
right: var(--pgn-spacing-toast-container-gutter-lg); right: var(--pgn-spacing-toast-container-gutter-lg);
} }

View File

@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Outlet } from 'react-router-dom'; import { MemoryRouter, Outlet } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initializeMockApp } from '@edx/frontend-platform/testing'; import { initializeMockApp } from '@edx/frontend-platform/testing';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import AuthZModule from './index'; import AuthZModule from './index';
jest.mock('./libraries-manager', () => ({ jest.mock('./libraries-manager', () => ({
@@ -32,11 +33,13 @@ describe('AuthZModule', () => {
const path = '/libraries/lib:123'; const path = '/libraries/lib:123';
render( render(
<QueryClientProvider client={queryClient}> <IntlProvider locale="en">
<MemoryRouter initialEntries={[path]}> <QueryClientProvider client={queryClient}>
<AuthZModule /> <MemoryRouter initialEntries={[path]}>
</MemoryRouter> <AuthZModule />
</QueryClientProvider>, </MemoryRouter>
</QueryClientProvider>
</IntlProvider>,
); );
expect(screen.getByTestId('loading-page')).toBeInTheDocument(); expect(screen.getByTestId('loading-page')).toBeInTheDocument();
@@ -51,11 +54,13 @@ describe('AuthZModule', () => {
const path = '/libraries/lib:123/testuser'; const path = '/libraries/lib:123/testuser';
render( render(
<QueryClientProvider client={queryClient}> <IntlProvider locale="en">
<MemoryRouter initialEntries={[path]}> <QueryClientProvider client={queryClient}>
<AuthZModule /> <MemoryRouter initialEntries={[path]}>
</MemoryRouter> <AuthZModule />
</QueryClientProvider>, </MemoryRouter>
</QueryClientProvider>
</IntlProvider>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument(); expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument();

View File

@@ -4,6 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query'; import { QueryErrorResetBoundary } from '@tanstack/react-query';
import LoadingPage from '@src/components/LoadingPage'; import LoadingPage from '@src/components/LoadingPage';
import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage'; import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage';
import { ToastManagerProvider } from './libraries-manager/ToastManagerContext';
import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager'; import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager';
import { ROUTES } from './constants'; import { ROUTES } from './constants';
@@ -13,14 +14,16 @@ const AuthZModule = () => (
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary fallbackRender={LibrariesErrorFallback} onReset={reset}> <ErrorBoundary fallbackRender={LibrariesErrorFallback} onReset={reset}>
<Suspense fallback={<LoadingPage />}> <ToastManagerProvider>
<Routes> <Suspense fallback={<LoadingPage />}>
<Route element={<LibrariesLayout />}> <Routes>
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} /> <Route element={<LibrariesLayout />}>
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} /> <Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
</Route> <Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
</Routes> </Route>
</Suspense> </Routes>
</Suspense>
</ToastManagerProvider>
</ErrorBoundary> </ErrorBoundary>
)} )}
</QueryErrorResetBoundary> </QueryErrorResetBoundary>

View File

@@ -1,9 +1,15 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { screen } from '@testing-library/react'; import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest'; import { renderWrapper } from '@src/setupTest';
import LibrariesUserManager from './LibrariesUserManager'; import LibrariesUserManager from './LibrariesUserManager';
import { useLibraryAuthZ } from './context'; import { useLibraryAuthZ } from './context';
import { useLibrary, useTeamMembers } from '../data/hooks'; import { useLibrary, useTeamMembers, useRevokeUserRoles } from '../data/hooks';
import { ToastManagerProvider } from './ToastManagerContext';
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@@ -17,18 +23,61 @@ jest.mock('./context', () => ({
jest.mock('../data/hooks', () => ({ jest.mock('../data/hooks', () => ({
useLibrary: jest.fn(), useLibrary: jest.fn(),
useTeamMembers: jest.fn(), useTeamMembers: jest.fn(),
useRevokeUserRoles: jest.fn(),
})); }));
jest.mock('../components/RoleCard', () => ({ jest.mock('../components/RoleCard', () => ({
__esModule: true, __esModule: true,
default: ({ title, description }: { title: string, description: string }) => ( default: ({
<div data-testid="role-card"> title,
description,
handleDelete,
}: {
title: string;
description: string;
handleDelete?: () => void;
}) => (
<div>
<div>{title}</div> <div>{title}</div>
<div>{description}</div> <div>{description}</div>
{handleDelete && (
<button type="button" onClick={handleDelete}>
{`delete-role-${title}`}
</button>
)}
</div> </div>
), ),
})); }));
jest.mock('./components/AssignNewRoleModal', () => ({
AssignNewRoleTrigger: () => <button type="button">Assign Role</button>,
}));
describe('LibrariesUserManager', () => { describe('LibrariesUserManager', () => {
const mockMutate = jest.fn();
const defaultMockData = {
libraryId: 'lib:123',
permissions: [{ key: 'view' }, { key: 'reuse' }],
roles: [
{
role: 'admin',
name: 'Admin',
description: 'Administrator Role',
permissions: ['view', 'reuse'],
userCount: 5,
},
{
role: 'instructor',
name: 'Instructor',
description: 'Instructor Role',
permissions: ['view'],
userCount: 10,
},
],
resources: [{ key: 'library', label: 'Library', description: '' }],
canManageTeam: true,
};
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -36,21 +85,7 @@ describe('LibrariesUserManager', () => {
(useParams as jest.Mock).mockReturnValue({ username: 'testuser' }); (useParams as jest.Mock).mockReturnValue({ username: 'testuser' });
// Mock library authz context // Mock library authz context
(useLibraryAuthZ as jest.Mock).mockReturnValue({ (useLibraryAuthZ as jest.Mock).mockReturnValue(defaultMockData);
libraryId: 'lib:123',
permissions: [{ key: 'view' }, { key: 'reuse' }],
roles: [
{
role: 'admin',
name: 'Admin',
description: 'Administrator Role',
permissions: ['view', 'reuse'],
},
],
resources: [
{ key: 'library', label: 'Library', description: '' },
],
});
// Mock library data // Mock library data
(useLibrary as jest.Mock).mockReturnValue({ (useLibrary as jest.Mock).mockReturnValue({
@@ -67,15 +102,31 @@ describe('LibrariesUserManager', () => {
{ {
username: 'testuser', username: 'testuser',
email: 'testuser@example.com', email: 'testuser@example.com',
roles: ['admin'], roles: ['admin', 'instructor'],
}, },
], ],
}, },
isLoading: false,
isFetching: false,
});
// Mock revoke user roles
(useRevokeUserRoles as jest.Mock).mockReturnValue({
mutate: mockMutate,
isPending: false,
}); });
}); });
const renderComponent = () => {
renderWrapper(
<ToastManagerProvider>
<LibrariesUserManager />
</ToastManagerProvider>,
);
};
it('renders the user roles correctly', () => { it('renders the user roles correctly', () => {
renderWrapper(<LibrariesUserManager />); renderComponent();
// Breadcrumb check // Breadcrumb check
expect(screen.getByText('Manage Access')).toBeInTheDocument(); expect(screen.getByText('Manage Access')).toBeInTheDocument();
@@ -85,8 +136,231 @@ describe('LibrariesUserManager', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('testuser'); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('testuser');
expect(screen.getByRole('paragraph')).toHaveTextContent('testuser@example.com'); expect(screen.getByRole('paragraph')).toHaveTextContent('testuser@example.com');
// RoleCard rendering expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getByTestId('role-card')).toHaveTextContent('Admin'); expect(screen.getByText('Instructor')).toBeInTheDocument();
expect(screen.getByTestId('role-card')).toHaveTextContent('Administrator Role');
defaultMockData.roles.forEach((role) => {
expect(screen.getByText(role.name)).toBeInTheDocument();
expect(screen.getByText(role.description)).toBeInTheDocument();
});
});
it('renders assign role trigger when user has canManageTeam permission', () => {
renderComponent();
expect(screen.getByText('Assign Role')).toBeInTheDocument();
});
describe('Revoking User Role Flow', () => {
it('opens confirmation modal when delete role button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
});
it('displays correct confirmation modal content', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to remove the Admin role from/)).toBeInTheDocument();
expect(screen.getByText(/Test Library/)).toBeInTheDocument();
});
});
it('closes confirmation modal when cancel button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const cancelButton = screen.getByText('Cancel');
await user.click(cancelButton);
await waitFor(() => {
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
});
});
it('calls revokeUserRoles mutation when Remove button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const removeButton = screen.getByText('Remove');
await user.click(removeButton);
expect(mockMutate).toHaveBeenCalledWith(
{
data: {
users: 'testuser',
role: 'admin',
scope: 'lib:123',
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it('shows success toast when role is revoked successfully with multiple roles remaining', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const removeButton = screen.getByText('Remove');
await user.click(removeButton);
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
await waitFor(() => {
expect(screen.getByText(/The Admin role has been successfully removed/)).toBeInTheDocument();
});
});
it('shows success toast with user removal message when last role is revoked', async () => {
const user = userEvent.setup();
(useTeamMembers as jest.Mock).mockReturnValue({
data: {
results: [
{
username: 'testuser',
email: 'testuser@example.com',
roles: ['admin'],
},
],
},
isLoading: false,
isFetching: false,
});
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const removeButton = screen.getByText('Remove');
await user.click(removeButton);
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
await waitFor(() => {
expect(screen.getByText(/The user no longer has access to this library/)).toBeInTheDocument();
});
});
it('shows error toast when role revocation fails', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const removeButton = screen.getByText('Remove');
await user.click(removeButton);
const onErrorCallback = mockMutate.mock.calls[0][1].onError;
onErrorCallback(new Error('Network error'));
await waitFor(() => {
expect(screen.getByText(/Something went wrong on our end/)).toBeInTheDocument();
});
});
it('closes confirmation modal after successful role revocation', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const removeButton = screen.getByText('Remove');
await user.click(removeButton);
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
await waitFor(() => {
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
});
});
it('disables delete action when revocation is in progress', async () => {
const user = userEvent.setup();
(useRevokeUserRoles as jest.Mock).mockReturnValue({
mutate: mockMutate,
isPending: true,
});
renderComponent();
const deleteButton = screen.getByText('delete-role-Admin');
await user.click(deleteButton);
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
expect(mockMutate).not.toHaveBeenCalled();
});
it('passes correct context to confirmation modal', async () => {
const user = userEvent.setup();
renderComponent();
const deleteButton = screen.getByText('delete-role-Instructor');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText('Remove role?')).toBeInTheDocument();
});
const modal = screen.getByRole('dialog');
expect(within(modal).getByText(/Instructor role/)).toBeInTheDocument();
expect(within(modal).getByText(/testuser/)).toBeInTheDocument();
expect(within(modal).getByText(/Test Library/)).toBeInTheDocument();
});
}); });
}); });

View File

@@ -1,24 +1,39 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { Container, Skeleton } from '@openedx/paragon'; import { Container, Skeleton } from '@openedx/paragon';
import { ROUTES } from '@src/authz-module/constants'; import { ROUTES } from '@src/authz-module/constants';
import { Role } from 'types';
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
import AuthZLayout from '../components/AuthZLayout'; import AuthZLayout from '../components/AuthZLayout';
import { useLibraryAuthZ } from './context'; import { useLibraryAuthZ } from './context';
import RoleCard from '../components/RoleCard'; import RoleCard from '../components/RoleCard';
import { AssignNewRoleTrigger } from './components/AssignNewRoleModal'; import { AssignNewRoleTrigger } from './components/AssignNewRoleModal';
import { useLibrary, useTeamMembers } from '../data/hooks'; import ConfirmDeletionModal from './components/ConfirmDeletionModal';
import { useLibrary, useRevokeUserRoles, useTeamMembers } from '../data/hooks';
import { buildPermissionMatrixByRole } from './utils'; import { buildPermissionMatrixByRole } from './utils';
import messages from './messages'; import messages from './messages';
const LibrariesUserManager = () => { const LibrariesUserManager = () => {
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate();
const { username } = useParams(); const { username } = useParams();
const { const {
libraryId, permissions, roles, resources, canManageTeam, libraryId, permissions, roles, resources, canManageTeam,
} = useLibraryAuthZ(); } = useLibraryAuthZ();
const teamMembersPath = `/authz/${ROUTES.LIBRARIES_TEAM_PATH.replace(':libraryId', libraryId)}`;
useEffect(() => {
if (!canManageTeam) {
navigate(teamMembersPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canManageTeam]);
const { data: library } = useLibrary(libraryId); const { data: library } = useLibrary(libraryId);
const { mutate: revokeUserRoles, isPending: isRevokingUserRole } = useRevokeUserRoles();
const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
const pageManageTitle = intl.formatMessage(messages['library.authz.manage.page.title']); const pageManageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
const querySettings = { const querySettings = {
@@ -30,7 +45,13 @@ const LibrariesUserManager = () => {
sortBy: null, sortBy: null,
}; };
const { data: teamMember, isLoading: isLoadingTeamMember } = useTeamMembers(libraryId, querySettings); const [roleToDelete, setRoleToDelete] = useState<Role | null>(null);
const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false);
const { handleShowToast, handleDiscardToast } = useToastManager();
const {
data: teamMember, isLoading: isLoadingTeamMember, isFetching: isFetchingMember,
} = useTeamMembers(libraryId, querySettings);
const user = teamMember?.results?.find(member => member.username === username); const user = teamMember?.results?.find(member => member.username === username);
const userRoles = useMemo(() => { const userRoles = useMemo(() => {
@@ -40,11 +61,76 @@ const LibrariesUserManager = () => {
}); });
}, [roles, user?.roles, permissions, resources, intl]); }, [roles, user?.roles, permissions, resources, intl]);
useEffect(() => {
if (!isFetchingMember) {
if (!isLoadingTeamMember && !user?.username) {
navigate(teamMembersPath);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFetchingMember, isLoadingTeamMember, user?.username]);
const handleCloseConfirmDeletionModal = () => {
setRoleToDelete(null);
setShowConfirmDeletionModal(false);
};
const handleShowConfirmDeletionModal = (role: Role) => {
if (isRevokingUserRole) { return; }
handleDiscardToast();
setRoleToDelete(role);
setShowConfirmDeletionModal(true);
};
const handleRevokeUserRole = () => {
if (!user || !roleToDelete) { return; }
const data = {
users: user.username,
role: roleToDelete.role,
scope: libraryId,
};
revokeUserRoles({ data }, {
onSuccess: () => {
const remainingRolesCount = userRoles.length - 1;
handleShowToast(intl.formatMessage(
messages['library.authz.team.remove.user.toast.success.description'],
{
role: roleToDelete.name,
rolesCount: remainingRolesCount,
},
));
handleCloseConfirmDeletionModal();
},
onError: (error) => {
logError(error);
// eslint-disable-next-line react/no-unstable-nested-components
handleShowToast(intl.formatMessage(messages['library.authz.team.default.error.toast.message'], { b: chunk => <b>{chunk}</b>, br: () => <br /> }));
handleCloseConfirmDeletionModal();
},
});
};
return ( return (
<div className="authz-libraries"> <div className="authz-libraries">
<ConfirmDeletionModal
isOpen={showConfirmDeletionModal}
close={handleCloseConfirmDeletionModal}
onSave={handleRevokeUserRole}
isDeleting={isRevokingUserRole}
context={{
userName: user?.username || '',
scope: library.title,
role: roleToDelete?.name || '',
rolesCount: userRoles.length,
}}
/>
<AuthZLayout <AuthZLayout
context={{ id: libraryId, title: library.title, org: library.org }} context={{ id: libraryId, title: library.title, org: library.org }}
navLinks={[{ label: rootBreadcrumb }, { label: pageManageTitle, to: `/authz/${ROUTES.LIBRARIES_TEAM_PATH.replace(':libraryId', libraryId)}` }]} navLinks={[{ label: rootBreadcrumb }, { label: pageManageTitle, to: teamMembersPath }]}
activeLabel={user?.username || ''} activeLabel={user?.username || ''}
pageTitle={user?.username || ''} pageTitle={user?.username || ''}
pageSubtitle={<p>{user?.email}</p>} pageSubtitle={<p>{user?.email}</p>}
@@ -64,7 +150,7 @@ const LibrariesUserManager = () => {
title={role.name} title={role.name}
objectName={library.title} objectName={library.title}
description={role.description} description={role.description}
showDelete handleDelete={() => handleShowConfirmDeletionModal(role)}
permissionsByResource={role.resources as any[]} permissionsByResource={role.resources as any[]}
/> />
))} ))}

View File

@@ -0,0 +1,176 @@
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { ToastManagerProvider, useToastManager } from './ToastManagerContext';
const render = (ui: React.ReactElement) => rtlRender(
<IntlProvider locale="en">
{ui}
</IntlProvider>,
);
const TestComponent = () => {
const { handleShowToast, handleDiscardToast } = useToastManager();
return (
<div>
<button type="button" onClick={() => handleShowToast('Test toast message')}>
Show Toast
</button>
<button type="button" onClick={() => handleShowToast('Another message')}>
Show Another Toast
</button>
<button type="button" onClick={handleDiscardToast}>
Discard Toast
</button>
</div>
);
};
describe('ToastManagerContext', () => {
describe('ToastManagerProvider', () => {
it('does not show toast initially', () => {
render(
<ToastManagerProvider>
<TestComponent />
</ToastManagerProvider>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows toast when handleShowToast is called', async () => {
const user = userEvent.setup();
render(
<ToastManagerProvider>
<TestComponent />
</ToastManagerProvider>,
);
// handleShowToast is called on button click
const showButton = screen.getByText('Show Toast');
await user.click(showButton);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Test toast message')).toBeInTheDocument();
});
});
it('updates toast message when handleShowToast is called with different message', async () => {
const user = userEvent.setup();
render(
<ToastManagerProvider>
<TestComponent />
</ToastManagerProvider>,
);
// Show first toast
const showButton = screen.getByText('Show Toast');
await user.click(showButton);
await waitFor(() => {
expect(screen.getByText('Test toast message')).toBeInTheDocument();
});
// Show another toast
const showAnotherButton = screen.getByText('Show Another Toast');
await user.click(showAnotherButton);
await waitFor(() => {
expect(screen.getByText('Another message')).toBeInTheDocument();
expect(screen.queryByText('Test toast message')).not.toBeInTheDocument();
});
});
it('hides toast when handleDiscardToast is called', async () => {
const user = userEvent.setup();
render(
<ToastManagerProvider>
<TestComponent />
</ToastManagerProvider>,
);
const showButton = screen.getByText('Show Toast');
await user.click(showButton);
await waitFor(() => {
expect(screen.getByText('Test toast message')).toBeInTheDocument();
});
// handleDiscardToast is called on button click
const discardButton = screen.getByText('Discard Toast');
await user.click(discardButton);
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
it('hides toast when close button is clicked', async () => {
const user = userEvent.setup();
render(
<ToastManagerProvider>
<TestComponent />
</ToastManagerProvider>,
);
const showButton = screen.getByText('Show Toast');
await user.click(showButton);
await waitFor(() => {
expect(screen.getByText('Test toast message')).toBeInTheDocument();
});
const closeButton = screen.getByLabelText('Close');
await user.click(closeButton);
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
it('calls handleClose callback when toast is closed', async () => {
const user = userEvent.setup();
const mockHandleClose = jest.fn();
render(
<ToastManagerProvider handleClose={mockHandleClose}>
<TestComponent />
</ToastManagerProvider>,
);
const showButton = screen.getByText('Show Toast');
await user.click(showButton);
await waitFor(() => {
expect(screen.getByText('Test toast message')).toBeInTheDocument();
});
const closeButton = screen.getByLabelText('Close');
await user.click(closeButton);
await waitFor(() => {
expect(mockHandleClose).toHaveBeenCalledTimes(1);
});
});
});
describe('useToastManager hook', () => {
it('throws error when used outside ToastManagerProvider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const TestComponentWithoutProvider = () => {
useToastManager();
return <div>Test</div>;
};
expect(() => {
render(<TestComponentWithoutProvider />);
}).toThrow('useToastManager must be used within an ToastManagerProvider');
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,57 @@
import {
createContext, useContext, useMemo, useState,
} from 'react';
import { Toast } from '@openedx/paragon';
type ToastManagerContextType = {
handleShowToast: (message: string) => void;
handleDiscardToast: () => void;
};
const ToastManagerContext = createContext<ToastManagerContextType | undefined>(undefined);
interface ToastManagerProviderProps {
handleClose?: () => void
children: React.ReactNode | React.ReactNode[];
}
export const ToastManagerProvider = ({ handleClose, children }: ToastManagerProviderProps) => {
const [toastMessage, setToastMessage] = useState<string | null>(null);
const handleShowToast = (message: string) => {
setToastMessage(message);
};
const handleDiscardToast = () => {
setToastMessage(null);
};
const value = useMemo((): ToastManagerContextType => ({
handleShowToast,
handleDiscardToast,
}), []);
return (
<ToastManagerContext.Provider value={value}>
{children}
<Toast
onClose={() => {
if (handleClose) { handleClose(); }
setToastMessage(null);
}}
show={!!toastMessage}
>
{toastMessage ?? ''}
</Toast>
</ToastManagerContext.Provider>
);
};
export const useToastManager = (): ToastManagerContextType => {
const context = useContext(ToastManagerContext);
if (context === undefined) {
throw new Error('useToastManager must be used within an ToastManagerProvider');
}
return context;
};

View File

@@ -0,0 +1,74 @@
import { FC } from 'react';
import {
ActionRow, AlertModal, Icon, ModalDialog, Stack,
StatefulButton,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { SpinnerSimple } from '@openedx/paragon/icons';
import messages from './messages';
interface ConfirmDeletionModalProps {
isOpen: boolean;
close: () => void;
onSave: () => void;
isDeleting?: boolean;
context: {
userName: string;
scope: string;
role: string;
rolesCount: number;
}
}
const ConfirmDeletionModal: FC<ConfirmDeletionModalProps> = ({
isOpen, close, onSave, isDeleting, context,
}) => {
const intl = useIntl();
return (
<AlertModal
title={intl.formatMessage(messages['library.authz.team.remove.user.modal.title'])}
isOpen={isOpen}
onClose={close}
size="lg"
footerNode={(
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages['libraries.authz.manage.cancel.button'])}
</ModalDialog.CloseButton>
<StatefulButton
className="px-4"
variant="danger"
labels={{
default: intl.formatMessage(messages['libraries.authz.manage.remove.button']),
pending: intl.formatMessage(messages['libraries.authz.manage.removing.button']),
}}
icons={{
pending: <Icon src={SpinnerSimple} />,
}}
state={isDeleting ? 'pending' : 'default'}
onClick={() => onSave()}
disabledStates={['pending']}
/>
</ActionRow>
)}
isOverflowVisible={false}
>
<Stack gap={3}>
<p>{intl.formatMessage(messages['library.authz.team.remove.user.modal.body.1'], {
userName: context.userName,
scope: context.scope,
role: context.role,
})}
</p>
{context.rolesCount === 1 && (
<p>{intl.formatMessage(messages['library.authz.team.remove.user.modal.body.2'])}</p>
)}
<p>{intl.formatMessage(messages['library.authz.team.remove.user.modal.body.3'])}</p>
</Stack>
</AlertModal>
);
};
export default ConfirmDeletionModal;

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Add New Role', defaultMessage: 'Add New Role',
description: 'Libraries AuthZ assign a new role to a user button title', description: 'Libraries AuthZ assign a new role to a user button title',
}, },
'library.authz.manage.role.select.label': {
id: 'library.authz.role.select.label',
defaultMessage: 'Roles',
description: 'Libraries team management label for roles select',
},
'libraries.authz.manage.cancel.button': { 'libraries.authz.manage.cancel.button': {
id: 'libraries.authz.manage.cancel.button', id: 'libraries.authz.manage.cancel.button',
defaultMessage: 'Cancel', defaultMessage: 'Cancel',
@@ -31,6 +26,41 @@ const messages = defineMessages({
defaultMessage: 'Role added successfully.', defaultMessage: 'Role added successfully.',
description: 'Libraries AuthZ assign role success message', description: 'Libraries AuthZ assign role success message',
}, },
'library.authz.team.remove.user.modal.title': {
id: 'library.authz.team.remove.user.modal.title',
defaultMessage: 'Remove role?',
description: 'Libraries team management remove user modal title',
},
'library.authz.team.remove.user.modal.body.1': {
id: 'library.authz.team.remove.user.modal.body',
defaultMessage: 'Are you sure you want to remove the {role} role from the user “{userName}” in the library {scope}?',
description: 'Libraries team management remove user modal body',
},
'library.authz.team.remove.user.modal.body.2': {
id: 'library.authz.team.remove.user.modal.body',
defaultMessage: "This is the user's only role in this library. Removing it will revoke their access completely, and they will no longer appear in the library's member List.",
description: 'Libraries team management remove user modal body',
},
'library.authz.team.remove.user.modal.body.3': {
id: 'library.authz.team.remove.user.modal.body',
defaultMessage: 'Are you sure you want to proceed?',
description: 'Libraries team management remove user modal body',
},
'library.authz.manage.role.select.label': {
id: 'library.authz.role.select.label',
defaultMessage: 'Roles',
description: 'Libraries team management label for roles select',
},
'libraries.authz.manage.removing.button': {
id: 'libraries.authz.manage.removing.button',
defaultMessage: 'Removing...',
description: 'Libraries AuthZ removing button title',
},
'libraries.authz.manage.remove.button': {
id: 'libraries.authz.manage.remove.button',
defaultMessage: 'Remove',
description: 'Libraries AuthZ remove button title',
},
}); });
export default messages; export default messages;

View File

@@ -26,6 +26,16 @@ const messages = defineMessages({
defaultMessage: 'Permissions', defaultMessage: 'Permissions',
description: 'Libreries AuthZ title for the permissions tab', description: 'Libreries AuthZ title for the permissions tab',
}, },
'library.authz.team.remove.user.toast.success.description': {
id: 'library.authz.team.remove.user.toast.success.description',
defaultMessage: 'The {role} role has been successfully removed.{rolesCount, plural, =0 { The user no longer has access to this library and has been removed from the member list.} other {}}',
description: 'Libraries team management remove user toast success',
},
'library.authz.team.default.error.toast.message': {
id: 'library.authz.team.default.error.toast.message',
defaultMessage: '<b>Something went wrong on our end</b> <br></br> Please try again later.',
description: 'Libraries team management remove user toast success',
},
}); });
export default messages; export default messages;