diff --git a/src/frontend/apps/impress/src/api/fetchApi.ts b/src/frontend/apps/impress/src/api/fetchApi.ts
index fc224367..fcf27c0c 100644
--- a/src/frontend/apps/impress/src/api/fetchApi.ts
+++ b/src/frontend/apps/impress/src/api/fetchApi.ts
@@ -15,7 +15,6 @@ function getCSRFToken() {
export const fetchAPI = async (input: string, init?: RequestInit) => {
const apiUrl = `${baseApiUrl()}${input}`;
- const { logout } = useAuthStore.getState();
const csrfToken = getCSRFToken();
@@ -30,6 +29,7 @@ export const fetchAPI = async (input: string, init?: RequestInit) => {
});
if (response.status === 401) {
+ const { logout } = useAuthStore.getState();
logout();
}
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx
new file mode 100644
index 00000000..adece224
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx
@@ -0,0 +1,101 @@
+import '@testing-library/jest-dom';
+import { render, screen } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+
+import { Access, Pad, Role } from '@/features/pads/pad-management';
+import { AppWrapper } from '@/tests/utils';
+
+import { MemberAction } from '../components/MemberAction';
+
+const access: Access = {
+ id: '789',
+ role: Role.ADMIN,
+ user: {
+ id: '11',
+ email: 'user1@test.com',
+ },
+ team: '',
+ abilities: {
+ set_role_to: [Role.READER, Role.ADMIN],
+ } as any,
+};
+
+const doc = {
+ id: '123456',
+ title: 'teamName',
+} as Pad;
+
+describe('MemberAction', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('checks the render when owner', async () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(
+ await screen.findByLabelText('Open the member options modal'),
+ ).toBeInTheDocument();
+ });
+
+ it('checks the render when reader', () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(
+ screen.queryByLabelText('Open the member options modal'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('checks the render when editor', () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(
+ screen.queryByLabelText('Open the member options modal'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('checks the render when admin', async () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(
+ await screen.findByLabelText('Open the member options modal'),
+ ).toBeInTheDocument();
+ });
+
+ it('checks the render when admin to owner', () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(
+ screen.queryByLabelText('Open the member options modal'),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx
new file mode 100644
index 00000000..027e2181
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx
@@ -0,0 +1,309 @@
+import '@testing-library/jest-dom';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+
+import { useAuthStore } from '@/core/auth';
+import { Access, Role } from '@/features/pads/pad-management';
+import { AppWrapper } from '@/tests/utils';
+
+import { ModalRole } from '../components/ModalRole';
+
+const toast = jest.fn();
+jest.mock('@openfun/cunningham-react', () => ({
+ ...jest.requireActual('@openfun/cunningham-react'),
+ useToastProvider: () => ({
+ toast,
+ }),
+}));
+
+HTMLDialogElement.prototype.showModal = jest.fn(function mock(
+ this: HTMLDialogElement,
+) {
+ this.open = true;
+});
+
+const access: Access = {
+ id: '789',
+ role: Role.ADMIN,
+ team: '123',
+ user: {
+ id: '11',
+ email: 'user1@test.com',
+ },
+ abilities: {
+ set_role_to: [Role.EDITOR, Role.ADMIN],
+ } as any,
+};
+
+describe('ModalRole', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('checks the cancel button', async () => {
+ const onClose = jest.fn();
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: 'Cancel',
+ }),
+ );
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('updates the role successfully', async () => {
+ fetchMock.mock(`end:/documents/123/accesses/789/`, {
+ status: 200,
+ ok: true,
+ });
+
+ const onClose = jest.fn();
+ render(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Administrator',
+ }),
+ ).toBeChecked();
+
+ await userEvent.click(
+ screen.getByRole('radio', {
+ name: 'Reader',
+ }),
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: 'Validate',
+ }),
+ );
+
+ await waitFor(() => {
+ expect(toast).toHaveBeenCalledWith(
+ 'The role has been updated',
+ 'success',
+ {
+ duration: 4000,
+ },
+ );
+ });
+
+ expect(fetchMock.lastUrl()).toContain(`/documents/123/accesses/789/`);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('fails to update the role', async () => {
+ fetchMock.patchOnce(`end:/documents/123/accesses/789/`, {
+ status: 500,
+ body: {
+ detail: 'The server is totally broken',
+ },
+ });
+
+ render(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ await userEvent.click(
+ screen.getByRole('radio', {
+ name: 'Reader',
+ }),
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: 'Validate',
+ }),
+ );
+
+ expect(
+ await screen.findByText('The server is totally broken'),
+ ).toBeInTheDocument();
+ });
+
+ it('checks the render when last owner', () => {
+ useAuthStore.setState({
+ userData: access.user,
+ });
+
+ const access2: Access = {
+ ...access,
+ role: Role.OWNER,
+ abilities: {
+ set_role_to: [],
+ } as any,
+ };
+
+ render(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ expect(
+ screen.getByText('You are the sole owner of this group.'),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByText(
+ 'Make another member the group owner, before you can change your own role.',
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Administrator',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Owner',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Reader',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Editor',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('button', {
+ name: 'Validate',
+ }),
+ ).toBeDisabled();
+ });
+
+ it('checks the render when it is another owner', () => {
+ useAuthStore.setState({
+ userData: {
+ id: '12',
+ email: 'username2@test.com',
+ },
+ });
+
+ const access2: Access = {
+ ...access,
+ role: Role.OWNER,
+ };
+
+ render(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ expect(
+ screen.getByText('You cannot update the role of other owner.'),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Administrator',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Owner',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Reader',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Editor',
+ }),
+ ).toBeDisabled();
+
+ expect(
+ screen.getByRole('button', {
+ name: 'Validate',
+ }),
+ ).toBeDisabled();
+ });
+
+ it('checks the render when current user is admin', () => {
+ render(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Editor',
+ }),
+ ).toBeEnabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Reader',
+ }),
+ ).toBeEnabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Administrator',
+ }),
+ ).toBeEnabled();
+
+ expect(
+ screen.getByRole('radio', {
+ name: 'Owner',
+ }),
+ ).toBeDisabled();
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts
new file mode 100644
index 00000000..a7e10d4b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts
@@ -0,0 +1,67 @@
+import {
+ UseMutationOptions,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query';
+
+import { APIError, errorCauses, fetchAPI } from '@/api';
+import { Access, KEY_PAD, Role } from '@/features/pads/pad-management';
+
+import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
+
+interface UpdateDocAccessProps {
+ docId: string;
+ accessId: string;
+ role: Role;
+}
+
+export const updateDocAccess = async ({
+ docId,
+ accessId,
+ role,
+}: UpdateDocAccessProps): Promise => {
+ const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ role,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new APIError('Failed to update role', await errorCauses(response));
+ }
+
+ return response.json() as Promise;
+};
+
+type UseUpdateDocAccess = Partial;
+
+type UseUpdateDocAccessOptions = UseMutationOptions<
+ Access,
+ APIError,
+ UseUpdateDocAccess
+>;
+
+export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: updateDocAccess,
+ ...options,
+ onSuccess: (data, variables, context) => {
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_DOC_ACCESSES],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_PAD],
+ });
+ if (options?.onSuccess) {
+ options.onSuccess(data, variables, context);
+ }
+ },
+ onError: (error, variables, context) => {
+ if (options?.onError) {
+ options.onError(error, variables, context);
+ }
+ },
+ });
+};
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx
new file mode 100644
index 00000000..b63989a2
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx
@@ -0,0 +1,90 @@
+import { Button } from '@openfun/cunningham-react';
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, DropButton, IconOptions, Text } from '@/components';
+import { Access, Pad, Role } from '@/features/pads/pad-management';
+
+import { ModalDelete } from './ModalDelete';
+import { ModalRole } from './ModalRole';
+
+interface MemberActionProps {
+ access: Access;
+ currentRole: Role;
+ doc: Pad;
+}
+
+export const MemberAction = ({
+ access,
+ currentRole,
+ doc,
+}: MemberActionProps) => {
+ const { t } = useTranslation();
+ const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
+ const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
+ const [isDropOpen, setIsDropOpen] = useState(false);
+
+ if (
+ currentRole === Role.EDITOR ||
+ currentRole === Role.READER ||
+ (access.role === Role.OWNER && currentRole === Role.ADMIN)
+ ) {
+ return null;
+ }
+
+ return (
+ <>
+
+ }
+ onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
+ isOpen={isDropOpen}
+ >
+
+
+
+
+
+ {isModalRoleOpen && (
+ setIsModalRoleOpen(false)}
+ docId={doc.id}
+ />
+ )}
+ {isModalDeleteOpen && (
+ setIsModalDeleteOpen(false)}
+ doc={doc}
+ />
+ )}
+ >
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx
new file mode 100644
index 00000000..bbc27ae4
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx
@@ -0,0 +1,130 @@
+import {
+ Button,
+ Modal,
+ ModalSize,
+ VariantType,
+ useToastProvider,
+} from '@openfun/cunningham-react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, Text, TextErrors } from '@/components';
+import { Access, Role } from '@/features/pads/pad-management';
+
+import { ChooseRole } from '../../members-add/components/ChooseRole';
+import { useUpdateDocAccess } from '../api';
+import { useWhoAmI } from '../hooks/useWhoAmI';
+
+interface ModalRoleProps {
+ access: Access;
+ currentRole: Role;
+ onClose: () => void;
+ docId: string;
+}
+
+export const ModalRole = ({
+ access,
+ currentRole,
+ onClose,
+ docId,
+}: ModalRoleProps) => {
+ const { t } = useTranslation();
+ const [localRole, setLocalRole] = useState(access.role);
+ const { toast } = useToastProvider();
+ const {
+ mutate: updateDocAccess,
+ error: errorUpdate,
+ isError: isErrorUpdate,
+ isPending,
+ } = useUpdateDocAccess({
+ onSuccess: () => {
+ toast(t('The role has been updated'), VariantType.SUCCESS, {
+ duration: 4000,
+ });
+ onClose();
+ },
+ });
+ const { isLastOwner, isOtherOwner } = useWhoAmI(access);
+
+ const isNotAllowed = isOtherOwner || isLastOwner;
+
+ return (
+ onClose()}
+ disabled={isPending}
+ >
+ {t('Cancel')}
+
+ }
+ onClose={() => onClose()}
+ closeOnClickOutside
+ hideCloseButton
+ rightActions={
+
+ }
+ size={ModalSize.MEDIUM}
+ title={t('Update the role')}
+ >
+
+ {isErrorUpdate && (
+
+ )}
+
+ {(isLastOwner || isOtherOwner) && (
+
+ warning
+ {isLastOwner && (
+
+
+ {t('You are the sole owner of this group.')}
+
+
+ {t(
+ 'Make another member the group owner, before you can change your own role.',
+ )}
+
+
+ )}
+
+ {isOtherOwner && t('You cannot update the role of other owner.')}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx
new file mode 100644
index 00000000..925952d3
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx
@@ -0,0 +1,20 @@
+import { useAuthStore } from '@/core/auth';
+import { Access, Role } from '@/features/pads/pad-management';
+
+export const useWhoAmI = (access: Access) => {
+ const { userData } = useAuthStore();
+
+ const isMyself = userData?.id === access.user.id;
+ 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;
+
+ return {
+ isLastOwner,
+ isOtherOwner,
+ isMyself,
+ };
+};