diff --git a/src/authz-module/components/AuthZTitle.test.tsx b/src/authz-module/components/AuthZTitle.test.tsx
index 1045912a..3f801fa4 100644
--- a/src/authz-module/components/AuthZTitle.test.tsx
+++ b/src/authz-module/components/AuthZTitle.test.tsx
@@ -65,4 +65,32 @@ describe('AuthZTitle', () => {
expect(onClick).toHaveBeenCalled();
});
});
+
+ it('renders action buttons with icons', () => {
+ const mockIcon = () => Icon;
+ const onClick = jest.fn();
+ const actions = [
+ { label: 'Save', icon: mockIcon, onClick },
+ ];
+
+ render();
+
+ const button = screen.getByRole('button', { name: 'Icon Save' });
+ expect(button).toBeInTheDocument();
+ expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
+ });
+
+ it('renders ReactNode actions alongside button actions', () => {
+ const onClick = jest.fn();
+ const customAction =
Custom Action
;
+ const actions = [
+ { label: 'Save', onClick },
+ customAction,
+ ];
+
+ render();
+
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ expect(screen.getByTestId('custom-action')).toBeInTheDocument();
+ });
});
diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx
index f0319fca..19c6c1a5 100644
--- a/src/authz-module/components/AuthZTitle.tsx
+++ b/src/authz-module/components/AuthZTitle.tsx
@@ -1,4 +1,4 @@
-import { ReactNode } from 'react';
+import { ComponentType, isValidElement, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import {
Breadcrumb, Col, Container, Row, Button, Badge,
@@ -11,6 +11,7 @@ interface BreadcrumbLink {
interface Action {
label: string;
+ icon?: ComponentType;
onClick: () => void;
}
@@ -19,7 +20,7 @@ export interface AuthZTitleProps {
pageTitle: string;
pageSubtitle: string | ReactNode;
navLinks?: BreadcrumbLink[];
- actions?: Action[];
+ actions?: (Action | ReactNode)[];
}
const AuthZTitle = ({
@@ -41,7 +42,22 @@ const AuthZTitle = ({
{
- actions.map(({ label, onClick }) => )
+ actions.map((action) => {
+ if (isValidElement(action)) {
+ return action;
+ }
+
+ const { label, icon, onClick } = action as Action;
+ return (
+
+ );
+ })
}
diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts
index 62feb715..3256bc2c 100644
--- a/src/authz-module/constants.ts
+++ b/src/authz-module/constants.ts
@@ -2,3 +2,11 @@ export const ROUTES = {
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
};
+
+export enum RoleOperationErrorStatus {
+ USER_NOT_FOUND = 'user_not_found',
+ USER_ALREADY_HAS_ROLE = 'user_already_has_role',
+ USER_DOES_NOT_HAVE_ROLE = 'user_does_not_have_role',
+ ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
+ ROLE_REMOVAL_ERROR = 'role_removal_error',
+}
diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts
index ea1e223b..86f5fb04 100644
--- a/src/authz-module/data/api.ts
+++ b/src/authz-module/data/api.ts
@@ -13,6 +13,16 @@ export type PermissionsByRole = {
permissions: string[];
userCount: number;
};
+export interface PutAssignTeamMembersRoleResponse {
+ completed: { user: string; status: string }[];
+ errors: { userIdentifier: string; error: string }[];
+}
+
+export interface AssignTeamMembersRoleRequest {
+ users: string[];
+ role: string;
+ scope: string;
+}
// TODO: replece api path once is created
export const getTeamMembers = async (object: string): Promise => {
@@ -20,6 +30,13 @@ export const getTeamMembers = async (object: string): Promise => {
return camelCaseObject(data.results);
};
+export const assignTeamMembersRole = async (
+ data: AssignTeamMembersRoleRequest,
+): Promise => {
+ const res = await getAuthenticatedHttpClient().put(getApiUrl('/api/authz/v1/roles/users/'), data);
+ return camelCaseObject(res.data);
+};
+
// TODO: this should be replaced in the future with Console API
export const getLibrary = async (libraryId: string): Promise => {
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));
diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx
index 79fafc43..ad22f54f 100644
--- a/src/authz-module/data/hooks.test.tsx
+++ b/src/authz-module/data/hooks.test.tsx
@@ -2,7 +2,9 @@ import { ReactNode } from 'react';
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 } from './hooks';
+import {
+ useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole,
+} from './hooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
@@ -155,4 +157,74 @@ describe('usePermissionsByRole', () => {
expect(e).toEqual(new Error('Not found'));
}
});
+
+ describe('useAssignTeamMembersRole', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('successfully adds team members', async () => {
+ const mockResponse = {
+ completed: [
+ {
+ user: 'jdoe',
+ status: 'role_added',
+ },
+ {
+ user: 'alice@example.com',
+ status: 'already_has_role',
+ },
+ ],
+ errors: [],
+ };
+
+ getAuthenticatedHttpClient.mockReturnValue({
+ put: jest.fn().mockResolvedValue({ data: mockResponse }),
+ });
+
+ const { result } = renderHook(() => useAssignTeamMembersRole(), {
+ wrapper: createWrapper(),
+ });
+
+ const addTeamMemberData = {
+ scope: 'lib:123',
+ users: ['jdoe'],
+ role: 'author',
+ };
+
+ await act(async () => {
+ result.current.mutate({ data: addTeamMemberData });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(getAuthenticatedHttpClient).toHaveBeenCalled();
+ expect(result.current.data).toEqual(mockResponse);
+ });
+
+ it('handles error when adding team members fails', async () => {
+ getAuthenticatedHttpClient.mockReturnValue({
+ put: jest.fn().mockRejectedValue(new Error('Failed to add members')),
+ });
+
+ const { result } = renderHook(() => useAssignTeamMembersRole(), {
+ wrapper: createWrapper(),
+ });
+
+ const addTeamMemberData = {
+ scope: 'lib:123',
+ users: ['jdoe'],
+ role: 'author',
+ };
+
+ await act(async () => {
+ result.current.mutate({ data: addTeamMemberData });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(getAuthenticatedHttpClient).toHaveBeenCalled();
+ expect(result.current.error).toEqual(new Error('Failed to add members'));
+ });
+ });
});
diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts
index 658f548b..195b5149 100644
--- a/src/authz-module/data/hooks.ts
+++ b/src/authz-module/data/hooks.ts
@@ -1,7 +1,11 @@
-import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
+import {
+ useMutation, useQuery, useQueryClient, useSuspenseQuery,
+} from '@tanstack/react-query';
import { appId } from '@src/constants';
import { LibraryMetadata, TeamMember } from '@src/types';
import {
+ assignTeamMembersRole,
+ AssignTeamMembersRoleRequest,
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
} from './api';
@@ -60,3 +64,23 @@ export const useLibrary = (libraryId: string) => useSuspenseQuery getLibrary(libraryId),
retry: false,
});
+
+/**
+ * React Query hook to add new team members to a specific scope or manage the corresponding roles.
+ * It provides a mutation function to add users with specified roles to the team or assign new roles.
+ *
+ * @example
+ * const { mutate: assignTeamMembersRole } = useAssignTeamMembersRole();
+ * assignTeamMembersRole({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } });
+ */
+export const useAssignTeamMembersRole = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ data }: {
+ data: AssignTeamMembersRoleRequest
+ }) => assignTeamMembersRole(data),
+ onSettled: (_data, _error, { data: { scope } }) => {
+ queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) });
+ },
+ });
+};
diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss
index 17f9719a..ab98fc34 100644
--- a/src/authz-module/index.scss
+++ b/src/authz-module/index.scss
@@ -34,4 +34,13 @@
height: var(--pgn-size-icon-xs);
}
}
+}
+
+
+.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/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx
index ad3a8d96..bbbb7ff7 100644
--- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx
+++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx
@@ -24,6 +24,11 @@ jest.mock('./components/TeamTable', () => ({
default: () => MockTeamTable
,
}));
+jest.mock('./components/AddNewTeamMemberModal', () => ({
+ __esModule: true,
+ AddNewTeamMemberTrigger: () => MockAddNewTeamMemberTrigger
,
+}));
+
describe('LibrariesTeamManager', () => {
beforeEach(() => {
initializeMockApp({
@@ -63,5 +68,8 @@ describe('LibrariesTeamManager', () => {
// TeamTable is rendered
expect(screen.getByTestId('team-table')).toBeInTheDocument();
+
+ // AddNewTeamMemberTrigger is rendered
+ expect(screen.getByTestId('add-team-member-trigger')).toBeInTheDocument();
});
});
diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx
index 48c13dcb..9595ce88 100644
--- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx
+++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx
@@ -1,15 +1,18 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
import { useLibrary } from '@src/authz-module/data/hooks';
+import { useLocation } from 'react-router-dom';
import TeamTable from './components/TeamTable';
import AuthZLayout from '../components/AuthZLayout';
import { useLibraryAuthZ } from './context';
+import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal';
import messages from './messages';
const LibrariesTeamManager = () => {
const intl = useIntl();
- const { libraryId } = useLibraryAuthZ();
+ const { hash } = useLocation();
+ const { libraryId, canManageTeam } = useLibraryAuthZ();
const { data: library } = useLibrary(libraryId);
const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
@@ -21,11 +24,15 @@ const LibrariesTeamManager = () => {
activeLabel={pageTitle}
pageTitle={pageTitle}
pageSubtitle={libraryId}
- actions={[]}
+ actions={
+ canManageTeam
+ ? []
+ : []
+ }
>
@@ -34,7 +41,7 @@ const LibrariesTeamManager = () => {
Role tab.
-
+
Permissions tab.
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.test.tsx
new file mode 100644
index 00000000..e4ad08a8
--- /dev/null
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.test.tsx
@@ -0,0 +1,244 @@
+import { screen } 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 AddNewTeamMemberModal from './AddNewTeamMemberModal';
+
+// Mock the context module
+jest.mock('@src/authz-module/libraries-manager/context', () => {
+ const actual = jest.requireActual('@src/authz-module/libraries-manager/context');
+ return {
+ ...actual,
+ useLibraryAuthZ: jest.fn(),
+ };
+});
+const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
+
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useTeamRoles: jest.fn(),
+}));
+
+const defaultProps = {
+ isOpen: true,
+ isLoading: false,
+ isError: false,
+ formValues: {
+ users: '',
+ role: '',
+ },
+ close: jest.fn(),
+ onSave: jest.fn(),
+ handleChangeForm: jest.fn(),
+};
+
+const mockRoles = [
+ {
+ role: 'instructor',
+ name: 'instructor',
+ description: 'Can create and edit content',
+ userCount: 3,
+ objects: [
+ {
+ object: 'library',
+ description: 'Library permissions',
+ actions: ['view', 'edit', 'delete'],
+ },
+ ],
+ },
+ {
+ role: 'admin',
+ name: 'admin',
+ description: 'Full access to the library',
+ userCount: 1,
+ objects: [
+ {
+ object: 'library',
+ description: 'Library permissions',
+ actions: ['view', 'edit', 'delete', 'manage'],
+ },
+ ],
+ },
+];
+
+describe('AddNewTeamMemberModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedUseLibraryAuthZ.mockReturnValue({
+ username: 'testuser',
+ libraryId: 'lib123',
+ roles: mockRoles,
+ canManageTeam: true,
+ });
+ });
+
+ const renderModal = (props = {}) => {
+ const finalProps = { ...defaultProps, ...props };
+ return renderWrapper(
+ ,
+ );
+ };
+
+ describe('Modal Rendering', () => {
+ it('renders the modal when isOpen is true', () => {
+ renderModal();
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Add New Team Member')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when isOpen is false', () => {
+ renderModal({ isOpen: false });
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Form Elements', () => {
+ it('renders the users textarea with correct label', () => {
+ renderModal();
+
+ expect(screen.getByLabelText('Add users by username or email')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /add users by username or email/i })).toBeInTheDocument();
+ });
+
+ it('renders the role select dropdown with correct label', () => {
+ renderModal();
+
+ expect(screen.getAllByLabelText('Roles')[0]).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /roles/i })).toBeInTheDocument();
+ });
+
+ it('renders role options correctly', () => {
+ renderModal();
+
+ expect(screen.getByText('Select a role')).toBeInTheDocument();
+ mockRoles.forEach((role) => {
+ expect(screen.getByText(role.role)).toBeInTheDocument();
+ });
+ });
+
+ it('displays form values correctly', () => {
+ renderModal({
+ formValues: {
+ users: 'user1@example.com, user2@example.com',
+ role: 'instructor',
+ },
+ });
+
+ expect(screen.getByDisplayValue('user1@example.com, user2@example.com')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('instructor')).toBeInTheDocument();
+ });
+ });
+
+ describe('Form Interactions', () => {
+ it('calls handleChangeForm when users textarea changes', async () => {
+ const user = userEvent.setup();
+ const handleChangeForm = jest.fn();
+ renderModal({ handleChangeForm });
+
+ const usersTextarea = screen.getByRole('textbox', { name: /add users by username or email/i });
+ await user.type(usersTextarea, 'test@example.com');
+
+ expect(handleChangeForm).toHaveBeenCalled();
+ });
+
+ it('calls handleChangeForm when role select changes', async () => {
+ const user = userEvent.setup();
+ const handleChangeForm = jest.fn();
+ renderModal({ handleChangeForm });
+
+ const roleSelect = screen.getByRole('combobox', { name: /roles/i });
+ await user.selectOptions(roleSelect, 'instructor');
+
+ expect(handleChangeForm).toHaveBeenCalled();
+ });
+ });
+
+ describe('Modal Actions', () => {
+ it('renders Cancel and Save buttons', () => {
+ renderModal();
+
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+ });
+
+ it('calls close function when Cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ const close = jest.fn();
+ renderModal({ close });
+
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onSave function when Save button is clicked', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn();
+ renderModal({
+ onSave,
+ formValues: {
+ users: 'test@example.com',
+ role: 'instructor',
+ },
+ });
+
+ await user.click(screen.getByRole('button', { name: /save/i }));
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables Save button when users field is empty', () => {
+ renderModal({
+ formValues: {
+ users: '',
+ role: 'instructor',
+ },
+ });
+
+ expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
+ });
+
+ it('disables Save button when role field is empty', () => {
+ renderModal({
+ formValues: {
+ users: 'test@example.com',
+ role: '',
+ },
+ });
+
+ expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
+ });
+
+ it('enables Save button when both fields are filled', () => {
+ renderModal({
+ formValues: {
+ users: 'test@example.com',
+ role: 'instructor',
+ },
+ });
+
+ expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
+ });
+ });
+
+ describe('Loading State', () => {
+ it('disables Cancel button when loading', () => {
+ renderModal({ isLoading: true });
+
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
+ });
+
+ it('disables Save button when loading', () => {
+ renderModal({
+ isLoading: true,
+ formValues: {
+ users: 'test@example.com',
+ role: 'instructor',
+ },
+ });
+
+ expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
+ });
+ });
+});
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx
new file mode 100644
index 00000000..e7d04348
--- /dev/null
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx
@@ -0,0 +1,127 @@
+import { FC, useRef } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow, Form, Hyperlink, Icon, IconButton, ModalDialog,
+ ModalPopup,
+ Stack,
+ StatefulButton,
+ useToggle,
+} from '@openedx/paragon';
+import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
+import { Info, SpinnerSimple } from '@openedx/paragon/icons';
+import messages from './messages';
+
+interface AddNewTeamMemberModalProps {
+ isOpen: boolean;
+ isError: boolean;
+ isLoading: boolean;
+ formValues: {
+ users: string;
+ role: string;
+ };
+ close: () => void;
+ onSave: () => void;
+ handleChangeForm: (e: React.ChangeEvent) => void;
+}
+
+const AddNewTeamMemberModal: FC = ({
+ isOpen, isError, isLoading, formValues, close, onSave, handleChangeForm,
+}) => {
+ const intl = useIntl();
+ const { roles } = useLibraryAuthZ();
+ const [isOpenRolesPopUp, openRolesPopUp, closeRolesPopUp] = useToggle(false);
+ const targetRolesPopUpRef = useRef(null);
+ return (
+ <>
+
+
+
+ {roles.map((role) => - {role.name}: {role.description}
)}
+
+
{intl.formatMessage(messages['libraries.authz.manage.tooltip.roles.extra.info'])}
+
+
+ { } : close}
+ size="lg"
+ variant="dark"
+ hasCloseButton
+ isOverflowVisible={false}
+ zIndex={5}
+ >
+
+
+ {intl.formatMessage(messages['libraries.authz.manage.add.member.title'])}
+
+
+
+
+
+
+ {intl.formatMessage(messages['libraries.authz.manage.add.member.description'])}
+
+
+
+ {intl.formatMessage(messages['libraries.authz.manage.add.member.users.label'])}
+ handleChangeForm(e)}
+ placeholder={intl.formatMessage(messages['libraries.authz.manage.add.member.users.placeholder'])}
+ style={{ color: isError && 'var(--pgn-color-form-feedback-invalid)' }}
+ />
+
+
+
+
+ {intl.formatMessage(messages['libraries.authz.manage.add.member.roles.label'])}
+
+
+ handleChangeForm(e)}>
+
+ {roles.map((role) => )}
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages['libraries.authz.manage.cancel.button'])}
+
+ ,
+ }}
+ state={isLoading ? 'pending' : 'default'}
+ onClick={() => onSave()}
+ disabledStates={['pending']}
+ disabled={isLoading || !formValues.users || !formValues.role}
+ />
+
+
+
+ >
+ );
+};
+
+export default AddNewTeamMemberModal;
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx
new file mode 100644
index 00000000..dc7603d0
--- /dev/null
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx
@@ -0,0 +1,290 @@
+import React from 'react';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWrapper } from '@src/setupTest';
+import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
+import AddNewTeamMemberTrigger from './AddNewTeamMemberTrigger';
+
+const mockMutate = jest.fn();
+
+// Mock the hooks module
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useAssignTeamMembersRole: jest.fn(),
+}));
+
+jest.mock('./AddNewTeamMemberModal', () => {
+ /* eslint-disable react/prop-types */
+ const MockModal = ({
+ isOpen, close, onSave, isLoading, formValues, handleChangeForm,
+ }) => (
+ isOpen ? (
+
+
+
+
+
+ {isLoading &&
Loading...
}
+
+ ) : null
+ );
+ /* eslint-enable react/prop-types */
+ return MockModal;
+});
+
+describe('AddNewTeamMemberTrigger', () => {
+ const mockLibraryId = 'lib:123';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useAssignTeamMembersRole as jest.Mock).mockReturnValue({
+ mutate: mockMutate,
+ isPending: false,
+ isError: false,
+ isSuccess: false,
+ } as any);
+ });
+
+ it('renders the trigger button', () => {
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /add new team member/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ it('opens modal when trigger button is clicked', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
+ });
+
+ it('closes modal when close button is clicked', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
+
+ const closeButton = screen.getByTestId('close-modal');
+ await user.click(closeButton);
+
+ expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
+ });
+
+ it('calls addTeamMember with correct data when save is clicked', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const usersInput = screen.getByTestId('users-input');
+ const roleSelect = screen.getByTestId('role-select');
+ const saveButton = screen.getByTestId('save-modal');
+
+ await user.type(usersInput, 'alice@example.com, bob@example.com');
+ await user.selectOptions(roleSelect, 'editor');
+ await user.click(saveButton);
+
+ expect(mockMutate).toHaveBeenCalledWith(
+ {
+ data: {
+ users: ['alice@example.com', 'bob@example.com'],
+ role: 'editor',
+ scope: mockLibraryId,
+ },
+ },
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ }),
+ );
+ });
+
+ it('displays success toast and closes modal on successful addition with no errors', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const saveButton = screen.getByTestId('save-modal');
+ await user.click(saveButton);
+
+ // Simulate successful response with no errors
+ const [, { onSuccess }] = mockMutate.mock.calls[0];
+ onSuccess({
+ completed: [
+ { userIdentifier: 'alice@example.com', status: 'role_added' },
+ { userIdentifier: 'bob@example.com', status: 'added_to_team' },
+ ],
+ errors: [],
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
+ });
+
+ expect(screen.getByText('2 team members added successfully.')).toBeInTheDocument();
+ });
+
+ it('displays mixed success and error toast on partial success', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const saveButton = screen.getByTestId('save-modal');
+ await user.click(saveButton);
+
+ // Simulate partial success response
+ const [, { onSuccess }] = mockMutate.mock.calls[0];
+ onSuccess({
+ completed: [
+ { userIdentifier: 'alice@example.com', status: 'role_added' },
+ ],
+ errors: [
+ { userIdentifier: 'unknown@example.com', error: 'user_not_found' },
+ ],
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/1 team member added successfully/)).toBeInTheDocument();
+ expect(screen.getByText(/We couldn't find a user for 1 email address or username/)).toBeInTheDocument();
+ });
+
+ // Modal should remain open when there are errors
+ expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
+ });
+
+ it('displays only error toast when all additions fail', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const saveButton = screen.getByTestId('save-modal');
+ await user.click(saveButton);
+
+ // Simulate all failed response
+ const [, { onSuccess }] = mockMutate.mock.calls[0];
+ onSuccess({
+ completed: [],
+ errors: [
+ { userIdentifier: 'unknown1@example.com', error: 'user_not_found' },
+ { userIdentifier: 'unknown2@example.com', error: 'user_not_found' },
+ ],
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/We couldn't find a user for 2 email addresses or usernames/)).toBeInTheDocument();
+ });
+
+ // Modal should remain open when there are errors
+ expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
+ });
+
+ it('resets form values after successful addition with no errors', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const usersInput = screen.getByTestId('users-input');
+ const roleSelect = screen.getByTestId('role-select');
+ const saveButton = screen.getByTestId('save-modal');
+
+ await user.type(usersInput, 'alice@example.com');
+ await user.selectOptions(roleSelect, 'editor');
+ await user.click(saveButton);
+
+ // Simulate successful response with no errors
+ const [, { onSuccess }] = mockMutate.mock.calls[0];
+ onSuccess({
+ completed: [{ userIdentifier: 'alice@example.com', status: 'role_added' }],
+ errors: [],
+ });
+
+ // Open modal again to check if form is reset
+ await user.click(triggerButton);
+
+ const newUsersInput = screen.getByTestId('users-input');
+ const newRoleSelect = screen.getByTestId('role-select');
+
+ expect(newUsersInput).toHaveValue('');
+ expect(newRoleSelect).toHaveValue('');
+ });
+
+ it('allows closing the success/error toast message', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const saveButton = screen.getByTestId('save-modal');
+ await user.click(saveButton);
+
+ // Simulate successful response
+ const [, { onSuccess }] = mockMutate.mock.calls[0];
+ onSuccess({
+ completed: [{ userIdentifier: 'alice@example.com', status: 'role_added' }],
+ errors: [],
+ });
+
+ // Toast should be visible
+ await waitFor(() => {
+ expect(screen.getByText('1 team member added successfully.')).toBeInTheDocument();
+ });
+
+ // Find and close the toast
+ const toastCloseButton = screen.getByLabelText(/close/i);
+ await user.click(toastCloseButton);
+
+ // Toast should be removed
+ await waitFor(() => {
+ expect(screen.queryByText('1 team member added successfully.')).not.toBeInTheDocument();
+ });
+ });
+
+ it('displays loading state when adding team member', async () => {
+ const user = userEvent.setup();
+
+ // Mock loading state
+ (useAssignTeamMembersRole as jest.Mock).mockReturnValue({
+ mutate: mockMutate,
+ isPending: true,
+ isError: false,
+ isSuccess: false,
+ } as any);
+
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ // Loading indicator should be visible in the modal
+ expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+ });
+});
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
new file mode 100644
index 00000000..a70b5424
--- /dev/null
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
@@ -0,0 +1,156 @@
+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 { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api';
+import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
+import { RoleOperationErrorStatus } from '@src/authz-module/constants';
+import AddNewTeamMemberModal from './AddNewTeamMemberModal';
+import messages from './messages';
+
+interface AddNewTeamMemberTriggerProps {
+ libraryId: string;
+}
+
+const DEFAULT_FORM_VALUES = {
+ users: '',
+ role: '',
+};
+
+const AddNewTeamMemberTrigger: FC = ({
+ libraryId,
+}) => {
+ const intl = useIntl();
+ const [isOpen, open, close] = useToggle(false);
+ const [showToast, setShowToast] = useState(false);
+ const [additionMessage, setAdditionMessage] = useState(null);
+ const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES);
+ const [isError, setIsError] = useState(false);
+ const [errorValidationUsers, setNotFoundUsers] = useState([]);
+ const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole();
+
+ const handleChangeForm = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ const userIds = value
+ .split(',')
+ .map(userId => userId.trim())
+ .filter(Boolean);
+ const hasErrorUser = errorValidationUsers.find((noUser) => userIds.includes(noUser));
+
+ if (hasErrorUser) {
+ setIsError(true);
+ } else {
+ setIsError(false);
+ }
+
+ setFormValues((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+ };
+
+ const handleErrors = (errors: PutAssignTeamMembersRoleResponse['errors']) => {
+ setIsError(false);
+ const notFoundUsers = errors.filter(err => err.error === RoleOperationErrorStatus.USER_NOT_FOUND)
+ .map(err => err.userIdentifier.trim());
+
+ if (errors.length === 1 && errors[0].error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE) {
+ setFormValues(DEFAULT_FORM_VALUES);
+ close();
+ }
+
+ if (notFoundUsers.length) {
+ setNotFoundUsers(notFoundUsers);
+ setIsError(true);
+ setFormValues((prev) => ({
+ ...prev,
+ users: notFoundUsers.join(', '),
+ }));
+
+ setAdditionMessage((prevMessage) => (
+ `${prevMessage ? `${prevMessage} ` : ''}${intl.formatMessage(
+ messages['libraries.authz.manage.add.member.failure'],
+ { count: notFoundUsers.length },
+ )}`
+ ));
+ setShowToast(true);
+ }
+ };
+
+ const handleAddTeamMember = () => {
+ const normalizedUsers = new Set(formValues.users.split(',').map(user => user.trim()).filter(user => user));
+ const data = {
+ users: [...normalizedUsers],
+ role: formValues.role,
+ scope: libraryId,
+ };
+
+ assignTeamMembersRole({ data }, {
+ onSuccess: (successData) => {
+ setAdditionMessage(null);
+
+ if (successData.completed.length) {
+ setAdditionMessage(
+ intl.formatMessage(
+ messages['libraries.authz.manage.add.member.success'],
+ { count: successData.completed.length },
+ ),
+ );
+ setShowToast(true);
+ }
+
+ if (successData.errors.length) {
+ handleErrors(successData.errors);
+ } else {
+ setIsError(false);
+ setNotFoundUsers([]);
+ close();
+ setFormValues(DEFAULT_FORM_VALUES);
+ }
+ },
+ });
+ };
+ const handleClose = () => {
+ setFormValues(DEFAULT_FORM_VALUES);
+ setNotFoundUsers([]);
+ setIsError(false);
+ setAdditionMessage(null);
+ close();
+ };
+
+ return (
+ <>
+
+
+ {isOpen && (
+
+ )}
+
+ {additionMessage && (
+ setShowToast(false)}
+ show={showToast}
+ >
+ {additionMessage}
+
+ )}
+ >
+ );
+};
+
+export default AddNewTeamMemberTrigger;
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/index.js b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/index.js
new file mode 100644
index 00000000..ee91c5f5
--- /dev/null
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/index.js
@@ -0,0 +1,4 @@
+import AddNewTeamMemberModal from './AddNewTeamMemberModal';
+import AddNewTeamMemberTrigger from './AddNewTeamMemberTrigger';
+
+export { AddNewTeamMemberModal, AddNewTeamMemberTrigger };
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts
new file mode 100644
index 00000000..46c5f050
--- /dev/null
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts
@@ -0,0 +1,71 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ 'libraries.authz.manage.add.member.title': {
+ id: 'libraries.authz.manage.add.member.title',
+ defaultMessage: 'Add New Team Member',
+ description: 'Title for the add new team member modal',
+ },
+ 'libraries.authz.manage.add.member.users.label': {
+ id: 'libraries.authz.manage.add.member.users.label',
+ defaultMessage: 'Add users by username or email',
+ description: 'Label for the users input field in the add new team member modal',
+ },
+ 'libraries.authz.manage.add.member.users.placeholder': {
+ id: 'libraries.authz.manage.add.member.users.placeholder',
+ defaultMessage: 'Enter one or more email addresses or usernames, comma-separated.',
+ description: 'Placeholder for the users input field in the add new team member modal',
+ },
+ 'libraries.authz.manage.add.member.roles.label': {
+ id: 'libraries.authz.manage.add.member.roles.label',
+ defaultMessage: 'Roles',
+ description: 'Label for the roles select field in the add new team member modal',
+ },
+ 'libraries.authz.manage.add.member.invalid.users': {
+ id: 'libraries.authz.manage.add.member.invalid.users',
+ defaultMessage: 'The following users could not be found:',
+ description: 'Error message for invalid users in the add new team member modal',
+ },
+ 'libraries.authz.manage.add.member.select.default': {
+ id: 'libraries.authz.manage.add.member.select.default',
+ defaultMessage: 'Select a role',
+ description: 'Default option for the roles select field in the add new team member modal',
+ },
+ '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.add.member.description': {
+ id: 'libraries.authz.manage.add.member.description',
+ defaultMessage: 'Add new members to this library\'s team and assign them a role to define their permissions.',
+ description: 'Description for the add new team member modal',
+ },
+ 'libraries.authz.manage.add.member.success': {
+ id: 'libraries.authz.manage.add.member.success',
+ defaultMessage: '{count, plural, one {# team member added successfully.} other {# team members added successfully.}}',
+ description: 'Success message when adding new team members',
+ },
+ 'libraries.authz.manage.add.member.failure': {
+ id: 'libraries.authz.manage.add.member.failure',
+ defaultMessage: 'We couldn\'t find a user for {count, plural, one {# email address or username.} other {# email addresses or usernames.}} Please check the values and try again, or invite them to join your organization first.',
+ description: 'Error message when adding new team members',
+ },
+ 'libraries.authz.manage.tooltip.roles.extra.info': {
+ id: 'libraries.authz.manage.tooltip.roles.extra.info',
+ defaultMessage: 'View detailed permissions for each role.',
+ description: 'Invite the user to check a detailed view of permissions',
+ },
+});
+
+export default messages;
diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx
index 9755c709..28f039d9 100644
--- a/src/authz-module/libraries-manager/context.test.tsx
+++ b/src/authz-module/libraries-manager/context.test.tsx
@@ -2,6 +2,7 @@ import { screen } from '@testing-library/react';
import { useParams } from 'react-router-dom';
import { useValidateUserPermissions } from '@src/data/hooks';
import { renderWrapper } from '@src/setupTest';
+import { usePermissionsByRole } from '@src/authz-module/data/hooks';
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
jest.mock('react-router-dom', () => ({
@@ -45,6 +46,34 @@ describe('LibraryAuthZProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
(useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' });
+ (usePermissionsByRole as jest.Mock).mockReturnValue({
+ data: [
+ {
+ role: 'instructor',
+ description: 'Can create and edit content',
+ userCount: 3,
+ objects: [
+ {
+ object: 'library',
+ description: 'Library permissions',
+ actions: ['view', 'edit', 'delete'],
+ },
+ ],
+ },
+ {
+ role: 'admin',
+ description: 'Full access to the library',
+ userCount: 1,
+ objects: [
+ {
+ object: 'library',
+ description: 'Library permissions',
+ actions: ['view', 'edit', 'delete', 'manage'],
+ },
+ ],
+ },
+ ],
+ });
});
it('provides the correct context values to consumers', () => {