diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx new file mode 100644 index 00000000..b0b37cad --- /dev/null +++ b/src/authz-module/audit-user/index.test.tsx @@ -0,0 +1,313 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { AppContext } from '@edx/frontend-platform/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ToastManagerProvider } from '@src/authz-module/data/context/ToastManagerContext'; +import AuditUserPage from './index'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), + configure: jest.fn(), // Add this line +})); + +const mockUser = { + username: 'johndoe', + email: 'john@example.com', + profile_image: { has_image: false }, +}; +const mockAssignments = { + count: 1, + results: [ + { + id: '1', + role: 'library_admin', + org: 'Test Org', + scope: 'lib:test', + permissionCount: 5, + }, + ], + next: null, + previous: null, +}; + +const renderWithRouter = (route = '/audit/johndoe') => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const mockAppContext = { + authenticatedUser: { + username: 'testuser', + email: 'testuser@example.com', + }, + config: { + // @ts-ignore + ...process.env, + }, + }; + + return render( + + + + + + + } /> + Home Page} /> + + + + + + , + ); +}; + +describe('AuditUserPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + // @ts-ignore + global.logError = jest.fn(); + }); + + it('renders user info and table when data is loaded', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'johndoe' })).toBeInTheDocument(); + expect(screen.getByText('john@example.com')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /assign role/i })).toBeInTheDocument(); + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + expect(screen.getByText('Test Org')).toBeInTheDocument(); + expect(screen.getByText('lib:test')).toBeInTheDocument(); + expect(screen.getByText('5 permissions available')).toBeInTheDocument(); + }); + }); + + it('navigates to home if user is not found', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: null }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + }); + }); + + it('allows user to interact with Assign Role button', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /assign role/i })).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + expect(button).not.toBeInTheDocument(); + }); + + it('renders empty state when user has no assignments', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ + data: { + count: 0, results: [], next: null, previous: null, + }, + }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'johndoe' })).toBeInTheDocument(); + expect(screen.queryByText('5 permissions available')).not.toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + }); + + it('renders correct table headers', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Role')).toBeInTheDocument(); + expect(screen.getByText('Organization')).toBeInTheDocument(); + expect(screen.getByText('Scope')).toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + }); + }); + + it('renders the pagination controls when assignments are present', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Showing 1 of 1.')).toBeInTheDocument(); + }); + }); + + it('renders the breadcrumb navigation with home link', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: /roles and permissions management/i })).toBeInTheDocument(); + expect(screen.getByText(mockUser.username, { selector: 'li[aria-current="page"]' })).toBeInTheDocument(); + }); + }); + + it('opens and closes the ConfirmDeletionModal when delete is clicked and cancel is pressed', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/remove role\?/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('calls onSave when confirming deletion in ConfirmDeletionModal', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + delete: jest.fn().mockResolvedValue({ data: { errors: [] } }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument(); + }); + + const removeButton = screen.getByRole('button', { name: /remove/i }); + await user.click(removeButton); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByText(/role has been successfully removed/i)).toBeInTheDocument(); + }); + }); + + it('shows the extra warning when rolesCount is 1', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ + data: { + count: 1, + results: [ + { + id: '1', + role: 'library_admin', + org: 'Test Org', + scope: 'lib:test', + permissionCount: 5, + }, + ], + next: null, + previous: null, + }, + }), + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText(/this is the user's only role/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx new file mode 100644 index 00000000..d18ffb3d --- /dev/null +++ b/src/authz-module/audit-user/index.tsx @@ -0,0 +1,215 @@ +import React, { useContext, useMemo, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; +import type { AppContextType } from '@edx/frontend-platform/react'; +import debounce from 'lodash.debounce'; +import { + Container, DataTable, +} from '@openedx/paragon'; +import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; +import { AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; +import AuthZLayout from '@src/authz-module/components/AuthZLayout'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useUserAccount } from '@src/data/hooks'; +import baseMessages from '@src/authz-module/messages'; +import AddRoleButton from '@src/authz-module/components/AddRoleButton'; +import { + OrgCell, RoleCell, ScopeCell, PermissionsCell, ViewAllPermissionsCell, + createActionsCell, +} from '@src/authz-module/components/TableCells'; +import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; +import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks'; +import { Role } from 'types'; +import { useToastManager } from '@src/authz-module/data/context/ToastManagerContext'; +import messages from './messages'; +import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; + +const AuditUserPage = () => { + const { formatMessage } = useIntl(); + const { username } = useParams(); + const { authenticatedUser } = useContext(AppContext) as AppContextType; + const navigate = useNavigate(); + const [roleToDelete, setRoleToDelete] = useState(null); + const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false); + const { + showToast, showErrorToast, Bold, Br, + } = useToastManager(); + const { mutate: revokeUserRoles, isPending: isRevokingUserRolePending } = useRevokeUserRoles(); + const { isLoading: isLoadingUser, data: user } = useUserAccount(username ?? ''); + const { querySettings, handleTableFetch } = useQuerySettings(); + const { data: { results: userAssignments, count } = { results: [] } } = useUserAssignedRoles(username ?? '', querySettings); + + if (!user && !isLoadingUser) { + navigate(AUTHZ_HOME_PATH); + } + const navLinks = [ + { + label: formatMessage(baseMessages['authz.management.home.nav.link']), + to: AUTHZ_HOME_PATH, + }, + ]; + + const columns = [ + { + Header: formatMessage(messages['authz.user.table.role.column.header']), + accessor: 'role', + Cell: RoleCell, + }, + { + Header: formatMessage(messages['authz.user.table.organization.column.header']), + accessor: 'org', + Cell: OrgCell, + }, + { + Header: formatMessage(messages['authz.user.table.scope.column.header']), + accessor: 'scope', + Cell: ScopeCell, + disableFilters: true, + }, + { + Header: formatMessage(messages['authz.user.table.permissions.column.header']), + Cell: PermissionsCell, + disableFilters: true, + disableSortBy: true, + }, + ]; + const pageCount = userAssignments?.length ? Math.ceil(userAssignments.length / TABLE_DEFAULT_PAGE_SIZE) : 1; + + const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]); + + const handleShowConfirmDeletionModal = (role: Role) => { + if (isRevokingUserRolePending) { return; } + + setRoleToDelete(role); + setShowConfirmDeletionModal(true); + }; + + const handleCloseConfirmDeletionModal = () => { + setRoleToDelete(null); + setShowConfirmDeletionModal(false); + }; + + const handleRevokeUserRole = () => { + if (!user || !roleToDelete) { return; } + + const data = { + users: user.username, + role: roleToDelete.role, + scope: roleToDelete.scope, + }; + + const runRevokeRole = (variables) => { + const variablesData = { + data: { + ...variables.data, + querySettings, + }, + + }; + revokeUserRoles(variablesData, { + onSuccess: (response) => { + const { errors } = response; + + if (errors.length) { + showToast({ + type: 'error', + message: formatMessage( + baseMessages['authz.team.toast.default.error.message'], + { Bold, Br }, + ), + }); + // authzQueryKeys.userRoles(username, querySettings), + return; + } + + const remainingRolesCount = count ? count - 1 : 0; + showToast({ + message: formatMessage( + baseMessages['authz.team.remove.user.toast.success.description'], + { + role: roleToDelete.name, + rolesCount: remainingRolesCount, + }, + ), + type: 'success', + }); + handleCloseConfirmDeletionModal(); + }, + onError: (error, retryVariables) => { + showErrorToast(error, () => runRevokeRole(retryVariables)); + }, + }); + }; + + runRevokeRole({ data }); + }; + + const additionalColumns = [ + { + id: 'view_permissions', + Header: '', + Cell: ViewAllPermissionsCell, + }, + { + id: 'action', + Header: formatMessage(messages['authz.user.table.action.column.header']), + Cell: createActionsCell({ + onClickDeleteButton: handleShowConfirmDeletionModal, + isUserAuthenticatedPage: username === authenticatedUser.username, + }), + }, + ]; + + return ( +
+ + , + ] + } + > + + + + + + + + +
+ ); +}; + +export default AuditUserPage; diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts new file mode 100644 index 00000000..dd3747fe --- /dev/null +++ b/src/authz-module/audit-user/messages.ts @@ -0,0 +1,48 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages( + { + 'authz.user.table.role.column.header': { + id: 'authz.user.table.role.column.header', + defaultMessage: 'Role', + description: 'Header for the role column in the user table', + }, + 'authz.user.table.organization.column.header': { + id: 'authz.user.table.organization.column.header', + defaultMessage: 'Organization', + description: 'Header for the organization column in the user table', + }, + 'authz.user.table.scope.column.header': { + id: 'authz.user.table.scope.column.header', + defaultMessage: 'Scope', + description: 'Header for the scope column in the user table', + }, + 'authz.user.table.permissions.column.header': { + id: 'authz.user.table.permissions.column.header', + defaultMessage: 'Permissions', + description: 'Header for the permissions column in the user table', + }, + 'authz.user.table.action.column.header': { + id: 'authz.user.table.action.column.header', + defaultMessage: 'Actions', + description: 'Header for the actions column in the user table', + }, + 'authz.user.table.view_all_permissions.link.text': { + id: 'authz.user.table.view_all_permissions.link.text', + defaultMessage: 'View all permissions', + description: 'Text for the link to view all permissions in the user table', + }, + 'authz.user.table.delete.action.alt': { + id: 'authz.user.table.delete.action.alt', + defaultMessage: 'Delete role action', + description: 'Alt description for delete button', + }, + 'authz.user.table.permissions.available.count': { + id: 'authz.user.table.permissions.available.count', + defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}', + description: 'Text showing the number of permissions available, with proper pluralization', + }, + }, +); + +export default messages; diff --git a/src/authz-module/components/AddRoleButton.test.tsx b/src/authz-module/components/AddRoleButton.test.tsx new file mode 100644 index 00000000..7765faf4 --- /dev/null +++ b/src/authz-module/components/AddRoleButton.test.tsx @@ -0,0 +1,134 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNavigate } from 'react-router-dom'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import AddRoleButton from './AddRoleButton'; + +// Mock react-router-dom navigation +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); + +describe('AddRoleButton', () => { + const mockNavigate = jest.fn(); + + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + beforeEach(() => { + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the assign role button with correct text', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button).toBeInTheDocument(); + }); + + it('displays the plus icon', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders correctly when presetUsername is provided', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button).toBeInTheDocument(); + }); + }); + + describe('navigation behavior', () => { + it('navigates to assign role page without username when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('navigates to assign role page with username query parameter when presetUsername is provided', async () => { + const user = userEvent.setup(); + const presetUsername = 'john.doe'; + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + }); + + it('handles special characters in presetUsername correctly', async () => { + const user = userEvent.setup(); + const presetUsername = 'user@example.com'; + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + }); + }); + + describe('user interactions', () => { + it('responds to keyboard navigation', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + + await user.tab(); + expect(button).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('responds to spacebar activation', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + button.focus(); + + await user.keyboard(' '); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('handles multiple clicks gracefully', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + + await user.click(button); + await user.click(button); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(3); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser'); + }); + }); +}); diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx new file mode 100644 index 00000000..8a44e750 --- /dev/null +++ b/src/authz-module/components/AddRoleButton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@openedx/paragon'; +import { Plus } from '@openedx/paragon/icons'; + +import baseMessages from '@src/authz-module/messages'; +import { useNavigate } from 'react-router-dom'; + +interface AddRoleButtonProps { + presetUsername?: string; +} + +const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + + const handleClick = () => { + const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`; + navigate(assignRolePath); + }; + + return ( + + ); +}; + +export default AddRoleButton; diff --git a/src/authz-module/components/AuthZLayout.tsx b/src/authz-module/components/AuthZLayout.tsx index 9845b90c..06b572cc 100644 --- a/src/authz-module/components/AuthZLayout.tsx +++ b/src/authz-module/components/AuthZLayout.tsx @@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps { const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => ( <> {children} diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx index a169c840..f17c7bf4 100644 --- a/src/authz-module/components/AuthZTitle.tsx +++ b/src/authz-module/components/AuthZTitle.tsx @@ -50,11 +50,11 @@ const AuthZTitle = ({ /> -
+

{pageTitle}

{typeof pageSubtitle === 'string' - ? <>

{pageSubtitle}

- : <>
{pageSubtitle}
} + ? <> { pageSubtitle !== '' &&
}

{pageSubtitle}

+ : <>{ pageSubtitle !== '' &&
}
{pageSubtitle}
}
diff --git a/src/authz-module/components/ConfirmDeletionModal.tsx b/src/authz-module/components/ConfirmDeletionModal.tsx new file mode 100644 index 00000000..152082ca --- /dev/null +++ b/src/authz-module/components/ConfirmDeletionModal.tsx @@ -0,0 +1,73 @@ +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 = ({ + isOpen, close, onSave, isDeleting, context, +}: ConfirmDeletionModalProps) => { + const intl = useIntl(); + return ( + + + {intl.formatMessage(messages['authz.manage.cancel.button'])} + + , + }} + state={isDeleting ? 'pending' : 'default'} + onClick={() => onSave()} + disabledStates={['pending']} + /> + + )} + isOverflowVisible={false} + > + +

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

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

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

+ )} +

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

+
+ +
+ ); +}; + +export default ConfirmDeletionModal; diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx new file mode 100644 index 00000000..8b972ca2 --- /dev/null +++ b/src/authz-module/components/TableCells.test.tsx @@ -0,0 +1,363 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import { + RoleCell, + OrgCell, + ScopeCell, + PermissionsCell, + ViewAllPermissionsCell, + createActionsCell, +} from './TableCells'; + +// TODO: remove console.log mocks and implement actual logic for these cells, then update tests accordingly +// Mock console.log for TODO functions +jest.spyOn(console, 'log').mockImplementation(() => {}); + +describe('TableCells Components', () => { + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + mail: 'test@example.com', + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('RoleCell', () => { + const mockCell = { + getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })), + }; + + it('renders the role label for a mapped role', () => { + const props = { + value: 'library_admin', + cell: mockCell, + row: { + original: { + role: 'library_admin', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Library Admin' }); + }); + + it('renders empty string for unmapped role', () => { + const props = { + value: 'unknown_role', + cell: mockCell, + row: { + original: { + role: 'unknown_role', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + const cellElement = screen.getByTestId('role-cell'); + expect(cellElement).toHaveTextContent(''); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': '' }); + }); + + it('applies cell props correctly', () => { + const props = { + value: 'course_staff', + cell: mockCell, + row: { + original: { + role: 'course_staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Course Staff' }); + }); + }); + + describe('OrgCell', () => { + it('displays "All Organizations" for Django superuser role', () => { + const props = { + value: 'Test Org', + row: { + original: { + role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); + }); + + it('displays "All Organizations" for Django global staff role', () => { + const props = { + value: 'Test Org', + row: { + original: { + role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); + }); + + it('displays the actual org value for non-Django roles', () => { + const props = { + value: 'Test Organization', + row: { + original: { + role: 'library_admin', org: 'Test Organization', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('Test Organization')).toBeInTheDocument(); + expect(screen.queryByText('All Organizations')).not.toBeInTheDocument(); + }); + }); + + describe('ScopeCell', () => { + it('displays "Global" for Django superuser role', () => { + const props = { + value: 'library', + row: { + original: { + role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('library')).not.toBeInTheDocument(); + }); + + it('displays "Global" for Django global staff role', () => { + const props = { + value: 'course', + row: { + original: { + role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('course')).not.toBeInTheDocument(); + }); + + it('displays the actual scope value for non-Django roles', () => { + const props = { + value: 'Course Scope', + row: { + original: { + role: 'course_admin', org: 'Test Org', scope: 'Course Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Course Scope')).toBeInTheDocument(); + expect(screen.queryByText('Global')).not.toBeInTheDocument(); + }); + }); + + describe('PermissionsCell', () => { + it('displays "Total Access" for Django superuser role', () => { + const props = { + row: { + original: { + role: 'django.superuser', + org: 'Test Org', + scope: 'Test Scope', + permissionCount: 10, + }, + }, + column: { id: 'permissions' }, + }; + + renderWrapper(); + + expect(screen.getByText('Total Access')).toBeInTheDocument(); + }); + + it('displays "Partial Access" for Django global staff role', () => { + const props = { + row: { + original: { + role: 'django.globalstaff', + permissionCount: 5, + org: 'Test Org', + scope: 'Test Scope', + }, + }, + column: { id: 'permissions' }, + }; + + renderWrapper(); + + expect(screen.getByText('Partial Access')).toBeInTheDocument(); + }); + + it('displays permission count for non-Django roles', () => { + const props = { + row: { + original: { + role: 'library_admin', + permissionCount: 3, + org: 'Test Org', + scope: 'Test Scope', + }, + }, + column: { id: 'permissions' }, + }; + + renderWrapper(); + + expect(screen.getByText('3 permissions available')).toBeInTheDocument(); + }); + }); + + describe('createActionsCell', () => { + const mockOnClickDeleteButton = jest.fn(); + const baseRow = { + original: { + role: 'library_admin', + org: 'Test Org', + scope: 'Test Scope', + permissionCount: 1, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a delete button and calls onClickDeleteButton when clicked', async () => { + const user = userEvent.setup(); + const CustomActionsCell = createActionsCell({ + onClickDeleteButton: mockOnClickDeleteButton, + isUserAuthenticatedPage: false, + }); + renderWrapper(); + + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + expect(deleteButton).toBeInTheDocument(); + + await user.click(deleteButton); + expect(mockOnClickDeleteButton).toHaveBeenCalledWith({ role: 'library_admin', scope: 'Test Scope' }); + }); + + it('renders a disabled button for admin roles when isUserAuthenticatedPage is true', () => { + const adminRow = { + original: { + role: 'course_admin', + org: 'Test Org', + scope: 'Test Scope', + permissionCount: 1, + }, + }; + const CustomActionsCell = createActionsCell({ + onClickDeleteButton: mockOnClickDeleteButton, + isUserAuthenticatedPage: true, + }); + renderWrapper(); + + const button = screen.getByRole('button', { name: /delete role action/i }); + expect(button).toBeDisabled(); + }); + + it('renders info icon with tooltip for Django managed roles', async () => { + const djangoRow = { + original: { + role: 'django.superuser', + org: 'Test Org', + scope: 'Test Scope', + permissionCount: 1, + }, + }; + const user = userEvent.setup(); + const CustomActionsCell = createActionsCell({ + onClickDeleteButton: mockOnClickDeleteButton, + isUserAuthenticatedPage: true, + }); + renderWrapper(); + + const infoIcon = screen.getByRole('img', { hidden: true }); + expect(infoIcon).toBeInTheDocument(); + await user.hover(infoIcon); + expect(screen.getByText(/Please go to Django Admin to manage it/i)).toBeInTheDocument(); + }); + }); + + describe('ViewAllPermissionsCell', () => { + const mockRow = { + original: { + role: 'library_admin', id: '123', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }; + + it('renders a view more link', () => { + const props = { + row: mockRow, + column: { id: 'viewMore' }, + }; + + renderWrapper(); + + expect(screen.getByText('View all permissions')).toBeInTheDocument(); + }); + + it('calls onClick handler when view more link is clicked', async () => { + const user = userEvent.setup(); + const props = { + row: mockRow, + column: { id: 'viewMore' }, + }; + + renderWrapper(); + + const viewMoreButton = screen.getByText('View all permissions'); + await user.click(viewMoreButton); + + // TODO: replace console.log with actual view more logic and update this test accordingly + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('View more clicked for row:', mockRow); + }); + }); +}); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx new file mode 100644 index 00000000..4560f923 --- /dev/null +++ b/src/authz-module/components/TableCells.tsx @@ -0,0 +1,138 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Role, TableCellValue, UserRole } from '@src/types'; +import { ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; +import { + Icon, IconButton, OverlayTrigger, Tooltip, +} from '@openedx/paragon'; +import { Delete, ExpandMore, Info } from '@openedx/paragon/icons'; +import messages from './messages'; +import ViewMoreLink from './ViewMoreLink'; + +type CellProps = TableCellValue; +type CellPropsWithValue = CellProps & { + value: string; +}; +type ExtendedCellProps = CellPropsWithValue & { + cell: { + getCellProps: (props?: Record) => Record; + }; +}; +type ActionsCellProps = CellProps & { + onClickDeleteButton: (role: Role) => void; + isUserAuthenticatedPage: boolean; +}; + +const RoleCell = ({ value, cell }: ExtendedCellProps) => ( + + {MAP_ROLE_KEY_TO_LABEL[value] || ''} + +); + +const OrgCell = ({ value, row }: CellPropsWithValue) => { + const { formatMessage } = useIntl(); + return ( + + {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} + + ); +}; + +const ScopeCell = ({ value, row }: CellPropsWithValue) => { + const { formatMessage } = useIntl(); + const isDjangoRole = DJANGO_MANAGED_ROLES.includes(row.original.role); + return ( + + {isDjangoRole ? formatMessage(messages['authz.user.table.scope.global.label']) : value} + + ); +}; + +const PermissionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + const { role, permissionCount: count } = row.original; + const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role); + return ( + + { isDjangoRole + ? formatMessage( + messages['authz.user.table.permissions.access.label'], + { accessType: role === 'django.superuser' ? 'total' : 'partial' }, + ) + : formatMessage(messages['authz.user.table.permissions.available.count'], { count })} + + ); +}; + +const ViewAllPermissionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + return ( + console.log('View more clicked for row:', row)} + iconSrc={ExpandMore} + /> + ); +}; + +const ActionsCell = ({ row, onClickDeleteButton, isUserAuthenticatedPage }: ActionsCellProps) => { + const { formatMessage } = useIntl(); + const { role } = row.original; + + const handleDelete = () => { + const roleToDelete = { + role, + scope: row.original.scope, + } as Role; + onClickDeleteButton(roleToDelete); + }; + + if (DJANGO_MANAGED_ROLES.includes(role)) { + return ( + + {formatMessage(messages['authz.user.table.delete.action.djangorole.tooltip'])} + + )} + > + + + ); + } + + if (ADMIN_ROLES.includes(role) && isUserAuthenticatedPage) { + return ( + + ); + } + + return ( + + ); +}; + +const createActionsCell = (extraProps) => function customActionsCell(cellProps) { + return ; +}; + +export { + RoleCell, + OrgCell, + ScopeCell, + PermissionsCell, + ViewAllPermissionsCell, + createActionsCell, +}; diff --git a/src/authz-module/components/TableFooter/TableFooter.test.tsx b/src/authz-module/components/TableFooter/TableFooter.test.tsx new file mode 100644 index 00000000..b03f4efe --- /dev/null +++ b/src/authz-module/components/TableFooter/TableFooter.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DataTableContext } from '@openedx/paragon'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import Footer from './TableFooter'; + +describe('TableFooter', () => { + const mockGotoPage = jest.fn(); + + const defaultDataTableContext = { + pageCount: 5, + gotoPage: mockGotoPage, + state: { + pageIndex: 0, + pageSize: 10, + }, + itemCount: 42, + rows: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + ], + }; + + const renderFooter = (contextOverrides = {}) => { + const contextValue = { + ...defaultDataTableContext, + ...contextOverrides, + }; + + return renderWrapper( + +