diff --git a/src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg b/src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg
new file mode 100644
index 0000000..4316c35
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx b/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx
index fc35c28..6a06da5 100644
--- a/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx
+++ b/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx
@@ -2,26 +2,28 @@ import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { DropButton, IconOptions, Text } from '@/components';
-import { Role } from '@/features/teams';
+import { Box, DropButton, IconOptions, Text } from '@/components';
+import { Role, Team } from '@/features/teams';
import { Access } from '../types';
+import { ModalDelete } from './ModalDelete';
import { ModalRole } from './ModalRole';
interface MemberActionProps {
access: Access;
currentRole: Role;
- teamId: string;
+ team: Team;
}
export const MemberAction = ({
access,
currentRole,
- teamId,
+ team,
}: MemberActionProps) => {
const { t } = useTranslation();
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
+ const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
if (
@@ -43,23 +45,45 @@ export const MemberAction = ({
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
-
+
+
+
+
{isModalRoleOpen && (
setIsModalRoleOpen(false)}
- teamId={teamId}
+ teamId={team.id}
+ />
+ )}
+ {isModalDeleteOpen && (
+ setIsModalDeleteOpen(false)}
+ team={team}
/>
)}
>
diff --git a/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx b/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx
index bbf0f8f..ed720c7 100644
--- a/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx
+++ b/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx
@@ -162,7 +162,7 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
renderCell: ({ row }) => {
return (
diff --git a/src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx b/src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx
new file mode 100644
index 0000000..c058c14
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx
@@ -0,0 +1,144 @@
+import {
+ Button,
+ Modal,
+ ModalSize,
+ VariantType,
+ useToastProvider,
+} from '@openfun/cunningham-react';
+import { t } from 'i18next';
+import { useRouter } from 'next/navigation';
+
+import IconUser from '@/assets/icons/icon-user.svg';
+import { Box, Text, TextErrors } from '@/components';
+import { useCunninghamTheme } from '@/cunningham';
+import { useAuthStore } from '@/features/auth';
+import { Role, Team } from '@/features/teams/';
+
+import { useDeleteTeamAccess } from '../api/useDeleteTeamAccess';
+import IconRemoveMember from '../assets/icon-remove-member.svg';
+import { Access } from '../types';
+
+interface ModalDeleteProps {
+ access: Access;
+ currentRole: Role;
+ onClose: () => void;
+ team: Team;
+}
+
+export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => {
+ const { userData } = useAuthStore();
+ const { toast } = useToastProvider();
+ const { colorsTokens } = useCunninghamTheme();
+ const router = useRouter();
+
+ const isMyself = userData?.id === access.user.id;
+
+ const {
+ mutate: removeTeamAccess,
+ error: errorUpdate,
+ isError: isErrorUpdate,
+ } = useDeleteTeamAccess({
+ onSuccess: () => {
+ toast(
+ t('The member has been removed from the team'),
+ VariantType.SUCCESS,
+ {
+ duration: 4000,
+ },
+ );
+
+ // If we remove ourselves, we redirect to the home page
+ // because we are no longer part of the team
+ isMyself ? router.push('/') : onClose();
+ },
+ });
+
+ const rolesAllowed = access.abilities.set_role_to;
+ const isLastOwner =
+ !rolesAllowed.length && access.role === Role.OWNER && isMyself;
+
+ const isOtherOwner = access.role === Role.OWNER && userData?.id && !isMyself;
+
+ const isNotAllowed = isOtherOwner || isLastOwner;
+
+ return (
+ onClose()}>
+ {t('Cancel')}
+
+ }
+ onClose={onClose}
+ rightActions={
+
+ }
+ size={ModalSize.MEDIUM}
+ title={
+
+
+
+ {t('Remove the member')}
+
+
+ }
+ >
+
+
+ {t(
+ 'Are you sure you want to remove this member from the {{team}} group?',
+ { team: team.name },
+ )}
+
+
+ {isErrorUpdate && (
+
+ )}
+
+ {(isLastOwner || isOtherOwner) && (
+
+ warning
+ {isLastOwner &&
+ t(
+ 'You are the last owner, you cannot be removed from your team.',
+ )}
+ {isOtherOwner && t('You cannot remove other owner.')}
+
+ )}
+
+
+
+ {access.user.name}
+
+
+
+ );
+};
diff --git a/src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts
new file mode 100644
index 0000000..b1ffb11
--- /dev/null
+++ b/src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts
@@ -0,0 +1,170 @@
+import { expect, test } from '@playwright/test';
+
+import { addNewMember, createTeam, keyCloakSignIn } from './common';
+
+test.beforeEach(async ({ page, browserName }) => {
+ await page.goto('/');
+ await keyCloakSignIn(page, browserName);
+});
+
+test.describe('Members Delete', () => {
+ test('it cannot delete himself when it is the last owner', async ({
+ page,
+ browserName,
+ }) => {
+ await createTeam(page, 'member-delete-1', browserName, 1);
+
+ const table = page.getByLabel('List members card').getByRole('table');
+
+ const cells = table.getByRole('row').nth(1).getByRole('cell');
+ await expect(cells.nth(1)).toHaveText(
+ new RegExp(`E2E ${browserName}`, 'i'),
+ );
+ await cells.nth(4).getByLabel('Member options').click();
+ await page.getByLabel('Open the modal to delete this member').click();
+
+ await expect(
+ page.getByText(
+ 'You are the last owner, you cannot be removed from your team.',
+ ),
+ ).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
+ });
+
+ test('it deletes himself when it is not the last owner', async ({
+ page,
+ browserName,
+ }) => {
+ await createTeam(page, 'member-delete-1', browserName, 1);
+
+ await addNewMember(page, 0, 'Owner');
+
+ const table = page.getByLabel('List members card').getByRole('table');
+
+ // find row where regexp match the name
+ const cells = table
+ .getByRole('row')
+ .filter({ hasText: new RegExp(`E2E ${browserName}`, 'i') })
+ .getByRole('cell');
+ await cells.nth(4).getByLabel('Member options').click();
+ await page.getByLabel('Open the modal to delete this member').click();
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+ await expect(
+ page.getByText(`The member has been removed from the team`),
+ ).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: `Create a new team` }),
+ ).toBeVisible();
+ });
+
+ test('it cannot delete owner member', async ({ page, browserName }) => {
+ await createTeam(page, 'member-delete-1', browserName, 1);
+
+ const username = await addNewMember(page, 0, 'Owner');
+
+ const table = page.getByLabel('List members card').getByRole('table');
+
+ // find row where regexp match the name
+ const cells = table
+ .getByRole('row')
+ .filter({ hasText: username })
+ .getByRole('cell');
+ await cells.nth(4).getByLabel('Member options').click();
+ await page.getByLabel('Open the modal to delete this member').click();
+
+ await expect(
+ page.getByText(`You cannot remove other owner.`),
+ ).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
+ });
+
+ test('it deletes admin member', async ({ page, browserName }) => {
+ await createTeam(page, 'member-delete-1', browserName, 1);
+
+ const username = await addNewMember(page, 0, 'Admin');
+
+ const table = page.getByLabel('List members card').getByRole('table');
+
+ // find row where regexp match the name
+ const cells = table
+ .getByRole('row')
+ .filter({ hasText: username })
+ .getByRole('cell');
+ await cells.nth(4).getByLabel('Member options').click();
+ await page.getByLabel('Open the modal to delete this member').click();
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+ await expect(
+ page.getByText(`The member has been removed from the team`),
+ ).toBeVisible();
+ await expect(table.getByText(username)).toBeHidden();
+ });
+
+ test('it cannot delete owner member when admin', async ({
+ page,
+ browserName,
+ }) => {
+ await createTeam(page, 'member-delete-1', browserName, 1);
+
+ const username = await addNewMember(page, 0, 'Owner');
+
+ const table = page.getByLabel('List members card').getByRole('table');
+
+ // find row where regexp match the name
+ const myCells = table
+ .getByRole('row')
+ .filter({ hasText: new RegExp(`E2E ${browserName}`, 'i') })
+ .getByRole('cell');
+ await myCells.nth(4).getByLabel('Member options').click();
+
+ // Change role to Admin
+ await page.getByText('Update the role').click();
+ const radioGroup = page.getByLabel('Radio buttons to update the roles');
+ await radioGroup.getByRole('radio', { name: 'Admin' }).click();
+ await page.getByRole('button', { name: 'Validate' }).click();
+
+ const cells = table
+ .getByRole('row')
+ .filter({ hasText: username })
+ .getByRole('cell');
+ await expect(cells.getByLabel('Member options')).toBeHidden();
+ });
+
+ test('it deletes admin member when admin', async ({ page, browserName }) => {
+ await createTeam(page, 'member-delete-1', browserName, 1);
+
+ // To not be the only owner
+ await addNewMember(page, 0, 'Owner');
+
+ const username = await addNewMember(page, 1, 'Admin', 'something');
+
+ const table = page.getByLabel('List members card').getByRole('table');
+
+ // find row where regexp match the name
+ const myCells = table
+ .getByRole('row')
+ .filter({ hasText: new RegExp(`E2E ${browserName}`, 'i') })
+ .getByRole('cell');
+ await myCells.nth(4).getByLabel('Member options').click();
+
+ // Change role to Admin
+ await page.getByText('Update the role').click();
+ const radioGroup = page.getByLabel('Radio buttons to update the roles');
+ await radioGroup.getByRole('radio', { name: 'Admin' }).click();
+ await page.getByRole('button', { name: 'Validate' }).click();
+
+ const cells = table
+ .getByRole('row')
+ .filter({ hasText: username })
+ .getByRole('cell');
+ await cells.nth(4).getByLabel('Member options').click();
+ await page.getByLabel('Open the modal to delete this member').click();
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+ await expect(
+ page.getByText(`The member has been removed from the team`),
+ ).toBeVisible();
+ await expect(table.getByText(username)).toBeHidden();
+ });
+});