diff --git a/src/authz-module/components/RoleCard/index.test.tsx b/src/authz-module/components/RoleCard/index.test.tsx index 6afc397..edfe900 100644 --- a/src/authz-module/components/RoleCard/index.test.tsx +++ b/src/authz-module/components/RoleCard/index.test.tsx @@ -20,7 +20,7 @@ describe('RoleCard', () => { title: 'Admin', objectName: 'Test Library', description: 'Can manage everything', - showDelete: true, + handleDelete: jest.fn(), userCounter: 2, permissionsByResource: [ { @@ -56,7 +56,7 @@ describe('RoleCard', () => { expect(screen.getByText('Can manage everything')).toBeInTheDocument(); // Delete button - expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Delete role action/i })).toBeInTheDocument(); // Collapsible title expect(screen.getByText('Permissions')).toBeInTheDocument(); @@ -75,8 +75,8 @@ describe('RoleCard', () => { expect(screen.getByTestId('manage-icon')).toBeInTheDocument(); }); - it('does not show delete button when showDelete is false', () => { - renderWrapper(); + it('does not show delete button when handleDelete is not passed', () => { + renderWrapper(); expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument(); }); diff --git a/src/authz-module/components/RoleCard/index.tsx b/src/authz-module/components/RoleCard/index.tsx index 350aed6..2da8c3d 100644 --- a/src/authz-module/components/RoleCard/index.tsx +++ b/src/authz-module/components/RoleCard/index.tsx @@ -14,7 +14,7 @@ interface CardTitleProps { interface RoleCardProps extends CardTitleProps { objectName?: string | null; description: string; - showDelete?: boolean; + handleDelete?: () => void; permissionsByResource: any[]; } @@ -31,7 +31,7 @@ const CardTitle = ({ title, userCounter = null }: CardTitleProps) => ( ); const RoleCard = ({ - title, objectName, description, showDelete, permissionsByResource, userCounter, + title, objectName, description, handleDelete, permissionsByResource, userCounter, }: RoleCardProps) => { const intl = useIntl(); @@ -41,7 +41,9 @@ const RoleCard = ({ title={} subtitle={(objectName && {objectName}) || ''} actions={ - showDelete && + handleDelete && ( + + ) } /> diff --git a/src/authz-module/components/RoleCard/messages.ts b/src/authz-module/components/RoleCard/messages.ts index e61fb1f..950a83e 100644 --- a/src/authz-module/components/RoleCard/messages.ts +++ b/src/authz-module/components/RoleCard/messages.ts @@ -46,6 +46,11 @@ const messages = defineMessages({ defaultMessage: 'Reuse {resource}', description: 'Default label for the reuse action', }, + 'authz.role.card.delete.action.alt': { + id: 'authz.role.card.delete.action.alt', + defaultMessage: 'Delete role action', + description: 'Alt description for delete button', + }, }); export default messages; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 0c59453..6fe08a8 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -17,6 +17,23 @@ export interface GetTeamMembersResponse { count: number; } +export type RevokeUserRolesRequest = { + users: string; + role: string; + scope: string; +}; + +export interface DeleteRevokeUserRolesResponse { + completed: { + userIdentifiers: string; + status: string; + }[], + errors: { + userIdentifiers: string; + error: string; + }[], +} + export type PermissionsByRole = { role: string; permissions: string[]; @@ -77,3 +94,16 @@ export const getPermissionsByRole = async (scope: string): Promise => { + 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); +}; diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index ee2643f..d55c7e0 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { - useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, + useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles, } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ @@ -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); + }); +}); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index e4ced66..c47ec60 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -5,7 +5,7 @@ import { appId } from '@src/constants'; import { LibraryMetadata } from '@src/types'; import { assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, - GetTeamMembersResponse, PermissionsByRole, QuerySettings, + GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, } from './api'; const authzQueryKeys = { @@ -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) }); + }, + }); +}; diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 907cd6c..96eb1a6 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -42,11 +42,10 @@ } } - .toast-container { // Ensure toast appears above modal z-index: 1000; // Move toast to the right left: auto; right: var(--pgn-spacing-toast-container-gutter-lg); -} \ No newline at end of file +} diff --git a/src/authz-module/index.test.tsx b/src/authz-module/index.test.tsx index 8963adc..15aac37 100644 --- a/src/authz-module/index.test.tsx +++ b/src/authz-module/index.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Outlet } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import AuthZModule from './index'; jest.mock('./libraries-manager', () => ({ @@ -32,11 +33,13 @@ describe('AuthZModule', () => { const path = '/libraries/lib:123'; render( - - - - - , + + + + + + + , ); expect(screen.getByTestId('loading-page')).toBeInTheDocument(); @@ -51,11 +54,13 @@ describe('AuthZModule', () => { const path = '/libraries/lib:123/testuser'; render( - - - - - , + + + + + + + , ); await waitFor(() => { expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument(); diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index d6e4218..42fd884 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -4,6 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; import LoadingPage from '@src/components/LoadingPage'; import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage'; +import { ToastManagerProvider } from './libraries-manager/ToastManagerContext'; import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager'; import { ROUTES } from './constants'; @@ -13,14 +14,16 @@ const AuthZModule = () => ( {({ reset }) => ( - }> - - }> - } /> - } /> - - - + + }> + + }> + } /> + } /> + + + + )} diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx index 80c679e..76dfa1c 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx @@ -1,9 +1,15 @@ 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 LibrariesUserManager from './LibrariesUserManager'; 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.requireActual('react-router-dom'), @@ -17,18 +23,61 @@ jest.mock('./context', () => ({ jest.mock('../data/hooks', () => ({ useLibrary: jest.fn(), useTeamMembers: jest.fn(), + useRevokeUserRoles: jest.fn(), })); + jest.mock('../components/RoleCard', () => ({ __esModule: true, - default: ({ title, description }: { title: string, description: string }) => ( -
+ default: ({ + title, + description, + handleDelete, + }: { + title: string; + description: string; + handleDelete?: () => void; + }) => ( +
{title}
{description}
+ {handleDelete && ( + + )}
), })); +jest.mock('./components/AssignNewRoleModal', () => ({ + AssignNewRoleTrigger: () => , +})); + 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(() => { jest.clearAllMocks(); @@ -36,21 +85,7 @@ describe('LibrariesUserManager', () => { (useParams as jest.Mock).mockReturnValue({ username: 'testuser' }); // Mock library authz context - (useLibraryAuthZ as jest.Mock).mockReturnValue({ - 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: '' }, - ], - }); + (useLibraryAuthZ as jest.Mock).mockReturnValue(defaultMockData); // Mock library data (useLibrary as jest.Mock).mockReturnValue({ @@ -67,15 +102,31 @@ describe('LibrariesUserManager', () => { { username: 'testuser', 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( + + + , + ); + }; + it('renders the user roles correctly', () => { - renderWrapper(); + renderComponent(); // Breadcrumb check expect(screen.getByText('Manage Access')).toBeInTheDocument(); @@ -85,8 +136,231 @@ describe('LibrariesUserManager', () => { expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('testuser'); expect(screen.getByRole('paragraph')).toHaveTextContent('testuser@example.com'); - // RoleCard rendering - expect(screen.getByTestId('role-card')).toHaveTextContent('Admin'); - expect(screen.getByTestId('role-card')).toHaveTextContent('Administrator Role'); + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('Instructor')).toBeInTheDocument(); + + 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(); + }); }); }); diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index e916f8e..1433a41 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -1,24 +1,39 @@ -import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; import { Container, Skeleton } from '@openedx/paragon'; 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 { useLibraryAuthZ } from './context'; import RoleCard from '../components/RoleCard'; 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 messages from './messages'; const LibrariesUserManager = () => { const intl = useIntl(); + const navigate = useNavigate(); const { username } = useParams(); const { libraryId, permissions, roles, resources, canManageTeam, } = 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 { mutate: revokeUserRoles, isPending: isRevokingUserRole } = useRevokeUserRoles(); const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; const pageManageTitle = intl.formatMessage(messages['library.authz.manage.page.title']); const querySettings = { @@ -30,7 +45,13 @@ const LibrariesUserManager = () => { sortBy: null, }; - const { data: teamMember, isLoading: isLoadingTeamMember } = useTeamMembers(libraryId, querySettings); + const [roleToDelete, setRoleToDelete] = useState(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 userRoles = useMemo(() => { @@ -40,11 +61,76 @@ const LibrariesUserManager = () => { }); }, [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 => {chunk}, br: () =>
})); + handleCloseConfirmDeletionModal(); + }, + }); + }; + return (
+ + {user?.email}

} @@ -64,7 +150,7 @@ const LibrariesUserManager = () => { title={role.name} objectName={library.title} description={role.description} - showDelete + handleDelete={() => handleShowConfirmDeletionModal(role)} permissionsByResource={role.resources as any[]} /> ))} diff --git a/src/authz-module/libraries-manager/ToastManagerContext.test.tsx b/src/authz-module/libraries-manager/ToastManagerContext.test.tsx new file mode 100644 index 0000000..28a7547 --- /dev/null +++ b/src/authz-module/libraries-manager/ToastManagerContext.test.tsx @@ -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( + + {ui} + , +); + +const TestComponent = () => { + const { handleShowToast, handleDiscardToast } = useToastManager(); + + return ( +
+ + + +
+ ); +}; + +describe('ToastManagerContext', () => { + describe('ToastManagerProvider', () => { + it('does not show toast initially', () => { + render( + + + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows toast when handleShowToast is called', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // 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( + + + , + ); + + // 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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
Test
; + }; + + expect(() => { + render(); + }).toThrow('useToastManager must be used within an ToastManagerProvider'); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/authz-module/libraries-manager/ToastManagerContext.tsx b/src/authz-module/libraries-manager/ToastManagerContext.tsx new file mode 100644 index 0000000..7862140 --- /dev/null +++ b/src/authz-module/libraries-manager/ToastManagerContext.tsx @@ -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(undefined); + +interface ToastManagerProviderProps { + handleClose?: () => void + children: React.ReactNode | React.ReactNode[]; +} + +export const ToastManagerProvider = ({ handleClose, children }: ToastManagerProviderProps) => { + const [toastMessage, setToastMessage] = useState(null); + + const handleShowToast = (message: string) => { + setToastMessage(message); + }; + + const handleDiscardToast = () => { + setToastMessage(null); + }; + + const value = useMemo((): ToastManagerContextType => ({ + handleShowToast, + handleDiscardToast, + }), []); + + return ( + + {children} + + { + if (handleClose) { handleClose(); } + setToastMessage(null); + }} + show={!!toastMessage} + > + {toastMessage ?? ''} + + + ); +}; + +export const useToastManager = (): ToastManagerContextType => { + const context = useContext(ToastManagerContext); + if (context === undefined) { + throw new Error('useToastManager must be used within an ToastManagerProvider'); + } + return context; +}; diff --git a/src/authz-module/libraries-manager/components/ConfirmDeletionModal.tsx b/src/authz-module/libraries-manager/components/ConfirmDeletionModal.tsx new file mode 100644 index 0000000..2b9066c --- /dev/null +++ b/src/authz-module/libraries-manager/components/ConfirmDeletionModal.tsx @@ -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 = ({ + isOpen, close, onSave, isDeleting, context, +}) => { + const intl = useIntl(); + return ( + + + {intl.formatMessage(messages['libraries.authz.manage.cancel.button'])} + + , + }} + state={isDeleting ? 'pending' : 'default'} + onClick={() => onSave()} + disabledStates={['pending']} + /> + + )} + isOverflowVisible={false} + > + +

{intl.formatMessage(messages['library.authz.team.remove.user.modal.body.1'], { + userName: context.userName, + scope: context.scope, + role: context.role, + })} +

+ {context.rolesCount === 1 && ( +

{intl.formatMessage(messages['library.authz.team.remove.user.modal.body.2'])}

+ )} +

{intl.formatMessage(messages['library.authz.team.remove.user.modal.body.3'])}

+
+ +
+ ); +}; + +export default ConfirmDeletionModal; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index d73643a..b69444f 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -6,11 +6,6 @@ const messages = defineMessages({ defaultMessage: 'Add New Role', 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': { id: 'libraries.authz.manage.cancel.button', defaultMessage: 'Cancel', @@ -31,6 +26,41 @@ const messages = defineMessages({ defaultMessage: 'Role added successfully.', 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; diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts index acba544..4e17426 100644 --- a/src/authz-module/libraries-manager/messages.ts +++ b/src/authz-module/libraries-manager/messages.ts @@ -26,6 +26,16 @@ const messages = defineMessages({ defaultMessage: 'Permissions', 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: 'Something went wrong on our end

Please try again later.', + description: 'Libraries team management remove user toast success', + }, }); export default messages;