diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 86f5fb04..d22b2140 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -24,7 +24,6 @@ export interface AssignTeamMembersRoleRequest { scope: string; } -// TODO: replece api path once is created export const getTeamMembers = async (object: string): Promise => { const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); return camelCaseObject(data.results); diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 9952ae1c..6883e818 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -6,6 +6,7 @@ import { ROUTES } from '@src/authz-module/constants'; 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 { buildPermissionsByRoleMatrix } from './utils'; @@ -15,7 +16,7 @@ const LibrariesUserManager = () => { const intl = useIntl(); const { username } = useParams(); const { - libraryId, permissions, roles, resources, + libraryId, permissions, roles, resources, canManageTeam, } = useLibraryAuthZ(); const { data: library } = useLibrary(libraryId); const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; @@ -42,7 +43,13 @@ const LibrariesUserManager = () => { activeLabel={user?.username || ''} pageTitle={user?.username || ''} pageSubtitle={

{user?.email}

} - actions={[]} + actions={user && canManageTeam + ? [ role.role)} + />] + : []} > {isLoading ? : null} diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx new file mode 100644 index 00000000..e8780570 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx @@ -0,0 +1,219 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { Role } from 'types'; +import AssignNewRoleModal from './AssignNewRoleModal'; + +describe('AssignNewRoleModal', () => { + const defaultProps = { + isOpen: true, + isLoading: false, + roleOptions: [ + { + role: 'instructor', + name: 'Instructor', + description: 'Can create and edit content', + userCount: 5, + permissions: ['view', 'edit'], + }, + { + role: 'admin', + name: 'Administrator', + description: 'Full access to the library', + userCount: 2, + permissions: ['view', 'edit', 'delete', 'manage'], + }, + { + role: 'viewer', + name: 'Viewer', + description: 'Can only view content', + userCount: 10, + permissions: ['view'], + }, + ] as Role[], + selectedRole: '', + close: jest.fn(), + onSave: jest.fn(), + handleChangeSelectedRole: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderComponent = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + return renderWrapper(); + }; + + describe('Modal Visibility', () => { + it('renders modal when isOpen is true', () => { + renderComponent({ isOpen: true }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Add New Role')).toBeInTheDocument(); + }); + + it('does not render modal when isOpen is false', () => { + renderComponent({ isOpen: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('Add New Role')).not.toBeInTheDocument(); + }); + }); + + describe('Modal Structure', () => { + it('renders modal header with correct title', () => { + renderComponent({ isOpen: true }); + + expect(screen.getByText('Add New Role')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('renders close button in header', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + }); + + describe('Role Selection Form', () => { + it('renders role selection form with correct label', () => { + renderComponent(); + + expect(screen.getByText('Roles')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders default option', () => { + renderComponent(); + + expect(screen.getByText('Select a role')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Select a role' })).toBeDisabled(); + }); + + it('renders all role options', () => { + renderComponent(); + + defaultProps.roleOptions.forEach((role) => { + expect(screen.getByRole('option', { name: role.name })).toBeInTheDocument(); + }); + }); + + it('displays selected role correctly', () => { + renderComponent({ selectedRole: 'instructor' }); + + const selectElement = screen.getByRole('combobox'); + expect(selectElement).toHaveValue('instructor'); + }); + + it('calls handleChangeSelectedRole when role selection changes', async () => { + const user = userEvent.setup(); + renderComponent(); + + const selectElement = screen.getByRole('combobox'); + await user.selectOptions(selectElement, 'admin'); + + expect(defaultProps.handleChangeSelectedRole).toHaveBeenCalled(); + }); + }); + + describe('Action Buttons', () => { + it('renders Cancel button with correct text', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('renders Save button with correct text when not loading', () => { + renderComponent({ isLoading: false }); + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('renders Save button with loading text when loading', () => { + renderComponent({ isLoading: true }); + + expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument(); + }); + + it('calls close when Cancel button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(defaultProps.close).toHaveBeenCalledTimes(1); + }); + + it('calls onSave when Save button is clicked', async () => { + const user = userEvent.setup(); + renderComponent({ selectedRole: 'instructor' }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + await user.click(saveButton); + + expect(defaultProps.onSave).toHaveBeenCalledTimes(1); + }); + }); + + describe('Button States', () => { + it('disables Save button when no role is selected', () => { + renderComponent({ selectedRole: '' }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeDisabled(); + }); + + it('enables Save button when role is selected and not loading', () => { + renderComponent({ selectedRole: 'instructor', isLoading: false }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).not.toBeDisabled(); + }); + + it('disables Save button when loading', () => { + renderComponent({ selectedRole: 'instructor', isLoading: true }); + + const saveButton = screen.getByRole('button', { name: /saving/i }); + expect(saveButton).toBeDisabled(); + }); + + it('disables Cancel button when loading', () => { + renderComponent({ isLoading: true }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).toBeDisabled(); + }); + + it('enables Cancel button when not loading', () => { + renderComponent({ isLoading: false }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).not.toBeDisabled(); + }); + }); + + describe('Modal Close Behavior', () => { + it('does not call close when modal header close is clicked during loading', async () => { + const user = userEvent.setup(); + renderComponent({ isLoading: true }); + + const headerCloseButton = screen.getByRole('button', { name: /close/i }); + await user.click(headerCloseButton); + + expect(defaultProps.close).not.toHaveBeenCalled(); + }); + + it('calls close when modal header close is clicked and not loading', async () => { + const user = userEvent.setup(); + renderComponent({ isLoading: false }); + + const headerCloseButton = screen.getByRole('button', { name: /close/i }); + await user.click(headerCloseButton); + + expect(defaultProps.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx new file mode 100644 index 00000000..0fca938f --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, Form, ModalDialog, +} from '@openedx/paragon'; +import { Role } from 'types'; +import messages from '../messages'; + +interface AssignNewRoleModalProps { + isOpen: boolean; + isLoading: boolean; + roleOptions: Role[]; + selectedRole: string; + close: () => void; + onSave: () => void; + handleChangeSelectedRole: (e: React.ChangeEvent) => void; +} + +const AssignNewRoleModal: FC = ({ + isOpen, isLoading, selectedRole, roleOptions, close, onSave, handleChangeSelectedRole, +}) => { + const intl = useIntl(); + return ( + {} : close} + size="lg" + variant="dark" + hasCloseButton + isOverflowVisible={false} + zIndex={5} + > + + + {intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])} + + + + + + {intl.formatMessage(messages['library.authz.team.table.roles'])} + + + {roleOptions.map((role) => )} + + + + + + + + {intl.formatMessage(messages['libraries.authz.manage.cancel.button'])} + + + + + + ); +}; + +export default AssignNewRoleModal; diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx new file mode 100644 index 00000000..f39ee35d --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx @@ -0,0 +1,265 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; +import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks'; +import AssignNewRoleTrigger from './AssignNewRoleTrigger'; + +jest.mock('@src/authz-module/libraries-manager/context', () => ({ + useLibraryAuthZ: jest.fn(), +})); + +jest.mock('@src/authz-module/data/hooks', () => ({ + useAssignTeamMembersRole: jest.fn(), +})); + +jest.mock('./AssignNewRoleModal', () => { + const MockAssignNewRoleModal = ({ + isOpen, + close, + onSave, + isLoading, + roleOptions, + selectedRole, + handleChangeSelectedRole, + }: any) => (isOpen ? ( +
+

Add New Role

+ + + +
+ ) : null); + MockAssignNewRoleModal.displayName = 'AssignNewRoleModal'; + return MockAssignNewRoleModal; +}); + +const mockUseLibraryAuthZ = useLibraryAuthZ as jest.Mock; +const mockUseAssignTeamMembersRole = useAssignTeamMembersRole as jest.Mock; + +describe('AssignNewRoleTrigger', () => { + const defaultProps = { + username: 'testuser', + libraryId: 'lib:test-library', + currentUserRoles: ['instructor'], + }; + + const mockRoles = [ + { + role: 'instructor', + name: 'Instructor', + description: 'Can create and edit content', + }, + { + role: 'admin', + name: 'Administrator', + description: 'Full access to the library', + }, + { + role: 'viewer', + name: 'Viewer', + description: 'Can only view content', + }, + ]; + + const mockMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseLibraryAuthZ.mockReturnValue({ + roles: mockRoles, + }); + + mockUseAssignTeamMembersRole.mockReturnValue({ + mutate: mockMutate, + isPending: false, + }); + }); + + const renderComponent = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + return renderWrapper(); + }; + + describe('Initial Render', () => { + it('renders the trigger button with correct text', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: /add new role/i })).toBeInTheDocument(); + }); + + it('does not show modal initially', () => { + renderComponent(); + + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + + it('does not show toast initially', () => { + renderComponent(); + + expect(screen.queryByText(/role added successfully/i)).not.toBeInTheDocument(); + }); + }); + + describe('Modal Interaction', () => { + it('opens modal when trigger button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const triggerButton = screen.getByRole('button', { name: /add new role/i }); + await user.click(triggerButton); + + expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /add new role/i })).toBeInTheDocument(); + }); + + it('closes modal when cancel button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + // Open modal + await user.click(screen.getByRole('button', { name: /add new role/i })); + expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument(); + + // Close modal + await user.click(screen.getByTestId('cancel-button')); + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Role Selection and Assignment', () => { + it('updates selected role when role select changes', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, 'admin'); + + expect(roleSelect).toHaveValue('admin'); + }); + + it('calls assignTeamMembersRole with correct data when save is clicked', async () => { + const choosenRole = mockRoles[1].role; // 'admin' + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + // Select a role + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, choosenRole); + + // Click save + await user.click(screen.getByTestId('save-button')); + + expect(mockMutate).toHaveBeenCalledWith( + { + data: { + users: [defaultProps.username], + role: choosenRole, + scope: defaultProps.libraryId, + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ); + }); + + it('does not call assignTeamMembersRole if user already has the selected role', async () => { + const choosenRole = mockRoles[1].role; // 'admin' + const user = userEvent.setup(); + renderComponent({ currentUserRoles: ['instructor', choosenRole] }); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + // Select a role that user already has + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, choosenRole); + + await user.click(screen.getByTestId('save-button')); + + // Should not call assignTeamMembersRole + expect(mockMutate).not.toHaveBeenCalled(); + // Modal should be closed + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('shows loading state in modal when assignment is pending', async () => { + mockUseAssignTeamMembersRole.mockReturnValue({ + mutate: mockMutate, + isPending: true, + }); + + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + expect(screen.getByTestId('save-button')).toBeDisabled(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + }); + + describe('Success Handling', () => { + it('shows success toast after successful role assignment', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, 'admin'); + + await user.click(screen.getByTestId('save-button')); + + // Simulate successful API call + const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess; + onSuccessCallback(); + + await waitFor(() => { + expect(screen.getByText(/role added successfully/i)).toBeInTheDocument(); + }); + }); + + it('closes modal and resets role after successful assignment', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, 'admin'); + + await user.click(screen.getByTestId('save-button')); + + // Simulate successful API call + const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess; + onSuccessCallback(); + + await waitFor(() => { + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + + // Open modal again to check if role is reset + await user.click(screen.getByRole('button', { name: /add new role/i })); + expect(screen.getByTestId('role-select')).toHaveValue(''); + }); + }); +}); diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx new file mode 100644 index 00000000..a0cc5085 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx @@ -0,0 +1,92 @@ +import React, { FC, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Toast, useToggle } from '@openedx/paragon'; +import { Plus } from '@openedx/paragon/icons'; + +import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; +import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks'; +import messages from '../messages'; +import AssignNewRoleModal from './AssignNewRoleModal'; + +interface AssignNewRoleTriggerProps { + username: string; + libraryId: string; + currentUserRoles: string[]; +} + +const AssignNewRoleTrigger: FC = ({ + username, + libraryId, + currentUserRoles, +}) => { + const intl = useIntl(); + const [isOpen, open, close] = useToggle(false); + const [toastMessage, setToastMessage] = useState(null); + const { roles } = useLibraryAuthZ(); + + const [newRole, setNewRole] = useState(''); + + const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole(); + + const handleAddRole = () => { + const data = { + users: [username], + role: newRole, + scope: libraryId, + }; + + if (currentUserRoles.includes(newRole)) { + close(); + setNewRole(''); + return; + } + + assignTeamMembersRole({ data }, { + onSuccess: () => { + setToastMessage( + intl.formatMessage( + messages['libraries.authz.manage.assign.role.success'], + ), + ); + close(); + setNewRole(''); + }, + }); + }; + + return ( + <> + + + {isOpen && ( + setNewRole(e.target.value)} + + /> + )} + + {toastMessage && ( + setToastMessage(null)} + show={!!toastMessage} + > + {toastMessage} + + )} + + ); +}; + +export default AssignNewRoleTrigger; diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts b/src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts new file mode 100644 index 00000000..2f8d6091 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts @@ -0,0 +1,4 @@ +import AssignNewRoleModal from './AssignNewRoleModal'; +import AssignNewRoleTrigger from './AssignNewRoleTrigger'; + +export { AssignNewRoleModal, AssignNewRoleTrigger }; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index 709ad1ef..d36537e7 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -31,6 +31,31 @@ const messages = defineMessages({ defaultMessage: 'Edit', description: 'Edit action', }, + 'libraries.authz.manage.assign.new.role.title': { + id: 'libraries.authz.manage.assign.new.role.title', + defaultMessage: 'Add New Role', + description: 'Libraries AuthZ assign a new role to a user button title', + }, + 'libraries.authz.manage.cancel.button': { + id: 'libraries.authz.manage.cancel.button', + defaultMessage: 'Cancel', + description: 'Libraries AuthZ cancel button title', + }, + 'libraries.authz.manage.saving.button': { + id: 'libraries.authz.manage.saving.button', + defaultMessage: 'Saving...', + description: 'Libraries AuthZ saving button title', + }, + 'libraries.authz.manage.save.button': { + id: 'libraries.authz.manage.save.button', + defaultMessage: 'Save', + description: 'Libraries AuthZ save button title', + }, + 'libraries.authz.manage.assign.role.success': { + id: 'libraries.authz.manage.assign.role.success', + defaultMessage: 'Role added successfully.', + description: 'Libraries AuthZ assign role success message', + }, }); export default messages;