feat: [FC-0099] allow deleting user's roles (#13)
Allows deleting users' roles from the users' roles view.
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
176
src/authz-module/libraries-manager/ToastManagerContext.test.tsx
Normal file
176
src/authz-module/libraries-manager/ToastManagerContext.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
57
src/authz-module/libraries-manager/ToastManagerContext.tsx
Normal file
57
src/authz-module/libraries-manager/ToastManagerContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user