diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61fe9eb91..c30ecfda2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@ and this project adheres to
- ✨(backend) domain accesses create API #428
- 🥅(frontend) catch new errors on mailbox creation #392
- ✨(api) domain accesses delete API #433
+- ✨(frontend) add mail domain access management #413
### Fixed
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/__tests__/accesses.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/__tests__/accesses.test.tsx
new file mode 100644
index 000000000..f74b449b1
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/__tests__/accesses.test.tsx
@@ -0,0 +1,143 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+import { useRouter as useNavigate } from 'next/navigation';
+import { useRouter } from 'next/router';
+
+import { AccessesContent } from '@/features/mail-domains/access-management';
+import { Role } from '@/features/mail-domains/domains';
+import MailDomainAccessesPage from '@/pages/mail-domains/[slug]/accesses';
+import { AppWrapper } from '@/tests/utils';
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}));
+
+jest.mock('next/router', () => ({
+ useRouter: jest.fn(),
+}));
+
+jest.mock(
+ '@/features/mail-domains/access-management/components/AccessesContent',
+ () => ({
+ AccessesContent: jest.fn(() =>
AccessContent
),
+ }),
+);
+
+describe('MailDomainAccessesPage', () => {
+ const mockRouterReplace = jest.fn();
+ const mockNavigate = { replace: mockRouterReplace };
+ const mockRouter = {
+ query: { slug: 'example-slug' },
+ };
+
+ (useRouter as jest.Mock).mockReturnValue(mockRouter);
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ fetchMock.reset();
+ (useRouter as jest.Mock).mockReturnValue(mockRouter);
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+ });
+
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ const renderPage = () => {
+ render(, { wrapper: AppWrapper });
+ };
+
+ it('renders loader while loading', () => {
+ // Simulate a never-resolving promise to mock loading
+ fetchMock.mock(
+ `end:/mail-domains/${mockRouter.query.slug}/`,
+ new Promise(() => {}),
+ );
+
+ renderPage();
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('renders error message when there is an error', async () => {
+ fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, {
+ status: 500,
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Something bad happens, please retry.'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('redirects to 404 page if the domain is not found', async () => {
+ fetchMock.mock(
+ `end:/mail-domains/${mockRouter.query.slug}/`,
+ {
+ body: { detail: 'Not found' },
+ status: 404,
+ },
+ { overwriteRoutes: true },
+ );
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(mockRouterReplace).toHaveBeenCalledWith('/404');
+ });
+ });
+
+ it('renders the AccessesContent when data is available', async () => {
+ const mockMailDomain = {
+ id: '1-1-1-1-1',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ abilities: {
+ get: true,
+ patch: true,
+ put: true,
+ post: true,
+ delete: true,
+ manage_accesses: true,
+ },
+ };
+
+ fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, {
+ body: mockMailDomain,
+ status: 200,
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText('AccessContent')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(AccessesContent).toHaveBeenCalledWith(
+ {
+ mailDomain: mockMailDomain,
+ currentRole: Role.OWNER,
+ },
+ {}, // adding this empty object is necessary to load jest context
+ );
+ });
+ });
+
+ it('throws an error when slug is invalid', () => {
+ console.error = jest.fn(); // Suppress expected error in jest logs
+
+ (useRouter as jest.Mock).mockReturnValue({
+ query: { slug: ['invalid-array-slug-in-array'] },
+ });
+
+ expect(() => renderPage()).toThrow('Invalid mail domain slug');
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useDeleteMailDomainAccess.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useDeleteMailDomainAccess.test.tsx
new file mode 100644
index 000000000..01a1f0fe8
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useDeleteMailDomainAccess.test.tsx
@@ -0,0 +1,109 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+
+import { APIError } from '@/api';
+import { AppWrapper } from '@/tests/utils';
+
+import {
+ deleteMailDomainAccess,
+ useDeleteMailDomainAccess,
+} from '../useDeleteMailDomainAccess';
+
+describe('deleteMailDomainAccess', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('deletes the access successfully', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 204, // No content status
+ });
+
+ await deleteMailDomainAccess({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ });
+
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-slug/accesses/1-1-1-1-1/',
+ );
+ });
+
+ it('throws an error when the API call fails', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ await expect(
+ deleteMailDomainAccess({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ }),
+ ).rejects.toThrow(APIError);
+ expect(fetchMock.calls()).toHaveLength(1);
+ });
+});
+
+describe('useDeleteMailDomainAccess', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('deletes the access and calls onSuccess callback', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 204, // No content status
+ });
+
+ const onSuccess = jest.fn();
+
+ const { result } = renderHook(
+ () => useDeleteMailDomainAccess({ onSuccess }),
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ result.current.mutate({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ });
+
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
+ await waitFor(() =>
+ expect(onSuccess).toHaveBeenCalledWith(
+ undefined,
+ { slug: 'example-slug', accessId: '1-1-1-1-1' },
+ undefined,
+ ),
+ );
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-slug/accesses/1-1-1-1-1/',
+ );
+ });
+
+ it('calls onError when the API fails', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ const onError = jest.fn();
+
+ const { result } = renderHook(
+ () => useDeleteMailDomainAccess({ onError }),
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ result.current.mutate({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ });
+
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
+ await waitFor(() => expect(onError).toHaveBeenCalled());
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useMailDomainAccesses.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useMailDomainAccesses.test.tsx
new file mode 100644
index 000000000..909297c61
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useMailDomainAccesses.test.tsx
@@ -0,0 +1,133 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+
+import { APIError } from '@/api';
+import { AppWrapper } from '@/tests/utils';
+
+import { Role } from '../../../domains';
+import { Access } from '../../types';
+import {
+ getMailDomainAccesses,
+ useMailDomainAccesses,
+} from '../useMailDomainAccesses';
+
+const mockAccess: Access = {
+ id: '1-1-1-1-1',
+ role: Role.ADMIN,
+ user: {
+ id: '2-1-1-1-1',
+ name: 'username1',
+ email: 'user1@test.com',
+ },
+ can_set_role_to: [Role.VIEWER, Role.ADMIN],
+};
+
+describe('getMailDomainAccesses', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('fetches the list of accesses successfully', async () => {
+ const mockResponse = {
+ count: 2,
+ results: [
+ mockAccess,
+ {
+ id: '2',
+ role: Role.VIEWER,
+ user: { id: '12', name: 'username2', email: 'user2@test.com' },
+ can_set_role_to: [Role.VIEWER],
+ },
+ ],
+ };
+
+ fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
+ status: 200,
+ body: mockResponse,
+ });
+
+ const result = await getMailDomainAccesses({
+ page: 1,
+ slug: 'example-slug',
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-slug/accesses/?page=1',
+ );
+ });
+
+ it('throws an error when the API call fails', async () => {
+ fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ await expect(
+ getMailDomainAccesses({ page: 1, slug: 'example-slug' }),
+ ).rejects.toThrow(APIError);
+ expect(fetchMock.calls()).toHaveLength(1);
+ });
+});
+
+describe('useMailDomainAccesses', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('fetches and returns the accesses data using the hook', async () => {
+ const mockResponse = {
+ count: 2,
+ results: [
+ mockAccess,
+ {
+ id: '2',
+ role: Role.VIEWER,
+ user: { id: '12', name: 'username2', email: 'user2@test.com' },
+ can_set_role_to: [Role.VIEWER],
+ },
+ ],
+ };
+
+ fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
+ status: 200,
+ body: mockResponse,
+ });
+
+ const { result } = renderHook(
+ () => useMailDomainAccesses({ page: 1, slug: 'example-slug' }),
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ await waitFor(() => result.current.isSuccess);
+
+ await waitFor(() => expect(result.current.data).toEqual(mockResponse));
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-slug/accesses/?page=1',
+ );
+ });
+
+ it('handles an API error properly with the hook', async () => {
+ fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ const { result } = renderHook(
+ () => useMailDomainAccesses({ page: 1, slug: 'example-slug' }),
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ await waitFor(() => result.current.isError);
+
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(APIError));
+ expect(result.current.error?.message).toBe('Failed to get the accesses');
+ expect(fetchMock.calls()).toHaveLength(1);
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useUpdateMailDomainAccess.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useUpdateMailDomainAccess.test.tsx
new file mode 100644
index 000000000..89a2aff30
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useUpdateMailDomainAccess.test.tsx
@@ -0,0 +1,139 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+
+import { APIError } from '@/api';
+import { AppWrapper } from '@/tests/utils';
+
+import { Role } from '../../../domains';
+import { Access } from '../../types';
+import {
+ updateMailDomainAccess,
+ useUpdateMailDomainAccess,
+} from '../useUpdateMailDomainAccess';
+
+const mockAccess: Access = {
+ id: '1-1-1-1-1',
+ role: Role.ADMIN,
+ user: {
+ id: '2-1-1-1-1',
+ name: 'username1',
+ email: 'user1@test.com',
+ },
+ can_set_role_to: [Role.VIEWER, Role.ADMIN],
+};
+
+describe('updateMailDomainAccess', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('updates the access role successfully', async () => {
+ const mockResponse = {
+ ...mockAccess,
+ role: Role.VIEWER,
+ };
+
+ fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 200,
+ body: mockResponse,
+ });
+
+ const result = await updateMailDomainAccess({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ role: Role.VIEWER,
+ });
+
+ expect(result).toEqual(mockResponse);
+ expect(fetchMock.calls()).toHaveLength(1);
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-slug/accesses/1-1-1-1-1/',
+ );
+ });
+
+ it('throws an error when the API call fails', async () => {
+ fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ await expect(
+ updateMailDomainAccess({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ role: Role.VIEWER,
+ }),
+ ).rejects.toThrow(APIError);
+ expect(fetchMock.calls()).toHaveLength(1);
+ });
+});
+
+describe('useUpdateMailDomainAccess', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('updates the role and calls onSuccess callback', async () => {
+ const mockResponse = {
+ ...mockAccess,
+ role: Role.VIEWER,
+ };
+
+ fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 200,
+ body: mockResponse,
+ });
+
+ const onSuccess = jest.fn();
+
+ const { result } = renderHook(
+ () => useUpdateMailDomainAccess({ onSuccess }),
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ result.current.mutate({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ role: Role.VIEWER,
+ });
+
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
+ await waitFor(() =>
+ expect(onSuccess).toHaveBeenCalledWith(
+ mockResponse, // data
+ { slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables
+ undefined, // context
+ ),
+ );
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-slug/accesses/1-1-1-1-1/',
+ );
+ });
+
+ it('calls onError when the API fails', async () => {
+ fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ const onError = jest.fn();
+
+ const { result } = renderHook(
+ () => useUpdateMailDomainAccess({ onError }),
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ result.current.mutate({
+ slug: 'example-slug',
+ accessId: '1-1-1-1-1',
+ role: Role.VIEWER,
+ });
+
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
+ await waitFor(() => expect(onError).toHaveBeenCalled());
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/index.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/index.ts
new file mode 100644
index 000000000..4f7ef78f4
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/index.ts
@@ -0,0 +1,3 @@
+export * from './useMailDomainAccesses';
+export * from './useUpdateMailDomainAccess';
+export * from './useDeleteMailDomainAccess';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainAccess.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainAccess.tsx
new file mode 100644
index 000000000..46f374320
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainAccess.tsx
@@ -0,0 +1,72 @@
+import {
+ UseMutationOptions,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query';
+
+import { APIError, errorCauses, fetchAPI } from '@/api';
+import {
+ KEY_LIST_MAIL_DOMAIN,
+ KEY_MAIL_DOMAIN,
+} from '@/features/mail-domains/domains';
+
+import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses';
+
+interface DeleteMailDomainAccessProps {
+ slug: string;
+ accessId: string;
+}
+
+export const deleteMailDomainAccess = async ({
+ slug,
+ accessId,
+}: DeleteMailDomainAccessProps): Promise => {
+ const response = await fetchAPI(
+ `mail-domains/${slug}/accesses/${accessId}/`,
+ {
+ method: 'DELETE',
+ },
+ );
+
+ if (!response.ok) {
+ throw new APIError(
+ 'Failed to delete the access',
+ await errorCauses(response),
+ );
+ }
+};
+
+type UseDeleteMailDomainAccessOptions = UseMutationOptions<
+ void,
+ APIError,
+ DeleteMailDomainAccessProps
+>;
+
+export const useDeleteMailDomainAccess = (
+ options?: UseDeleteMailDomainAccessOptions,
+) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: deleteMailDomainAccess,
+ ...options,
+ onSuccess: (data, variables, context) => {
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_MAIL_DOMAIN],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_MAIL_DOMAIN],
+ });
+ 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/desk/src/features/mail-domains/access-management/api/useMailDomainAccesses.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useMailDomainAccesses.tsx
new file mode 100644
index 000000000..75ddfeb64
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useMailDomainAccesses.tsx
@@ -0,0 +1,49 @@
+import { UseQueryOptions, useQuery } from '@tanstack/react-query';
+
+import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
+
+import { Access } from '../types';
+
+export type MailDomainAccessesAPIParams = {
+ page: number;
+ slug: string;
+ ordering?: string;
+};
+
+type AccessesResponse = APIList;
+
+export const getMailDomainAccesses = async ({
+ page,
+ slug,
+ ordering,
+}: MailDomainAccessesAPIParams): Promise => {
+ let url = `mail-domains/${slug}/accesses/?page=${page}`;
+
+ if (ordering) {
+ url += '&ordering=' + ordering;
+ }
+
+ const response = await fetchAPI(url);
+
+ if (!response.ok) {
+ throw new APIError(
+ 'Failed to get the accesses',
+ await errorCauses(response),
+ );
+ }
+
+ return response.json() as Promise;
+};
+
+export const KEY_LIST_MAIL_DOMAIN_ACCESSES = 'mail-domains-accesses';
+
+export function useMailDomainAccesses(
+ params: MailDomainAccessesAPIParams,
+ queryConfig?: UseQueryOptions,
+) {
+ return useQuery({
+ queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES, params],
+ queryFn: () => getMailDomainAccesses(params),
+ ...queryConfig,
+ });
+}
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUpdateMailDomainAccess.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUpdateMailDomainAccess.ts
new file mode 100644
index 000000000..92712e38c
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUpdateMailDomainAccess.ts
@@ -0,0 +1,74 @@
+import {
+ UseMutationOptions,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query';
+
+import { APIError, errorCauses, fetchAPI } from '@/api';
+import { KEY_MAIL_DOMAIN, Role } from '@/features/mail-domains/domains';
+
+import { Access } from '../types';
+
+import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses';
+
+interface UpdateMailDomainAccessProps {
+ slug: string;
+ accessId: string;
+ role: Role;
+}
+
+export const updateMailDomainAccess = async ({
+ slug,
+ accessId,
+ role,
+}: UpdateMailDomainAccessProps): Promise => {
+ const response = await fetchAPI(
+ `mail-domains/${slug}/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 UseUpdateMailDomainAccess = Partial;
+
+type UseUpdateMailDomainAccessOptions = UseMutationOptions<
+ Access,
+ APIError,
+ UseUpdateMailDomainAccess
+>;
+
+export const useUpdateMailDomainAccess = (
+ options?: UseUpdateMailDomainAccessOptions,
+) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: updateMailDomainAccess,
+ ...options,
+ onSuccess: (data, variables, context) => {
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_MAIL_DOMAIN],
+ });
+ 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/desk/src/features/mail-domains/access-management/assets/icon-remove-member.svg b/src/frontend/apps/desk/src/features/mail-domains/access-management/assets/icon-remove-member.svg
new file mode 100644
index 000000000..4316c35c5
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/assets/icon-remove-member.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessAction.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessAction.tsx
new file mode 100644
index 000000000..b8f1f053f
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessAction.tsx
@@ -0,0 +1,104 @@
+import { Button } from '@openfun/cunningham-react';
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, DropButton, IconOptions, Text } from '@/components';
+
+import { MailDomain, Role } from '../../domains/types';
+import { Access } from '../types';
+
+import { ModalDelete } from './ModalDelete';
+import { ModalRole } from './ModalRole';
+
+interface AccessActionProps {
+ access: Access;
+ currentRole: Role;
+ mailDomain: MailDomain;
+}
+
+export const AccessAction = ({
+ access,
+ currentRole,
+ mailDomain,
+}: AccessActionProps) => {
+ const { t } = useTranslation();
+ const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
+ const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
+ const [isDropOpen, setIsDropOpen] = useState(false);
+
+ if (
+ currentRole === Role.VIEWER ||
+ (access.role === Role.OWNER && currentRole === Role.ADMIN)
+ ) {
+ return null;
+ }
+
+ return (
+ <>
+
+ }
+ onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
+ isOpen={isDropOpen}
+ >
+
+ {(mailDomain.abilities.put || mailDomain.abilities.patch) && (
+
+ )}
+ {mailDomain.abilities.delete && (
+
+ )}
+
+
+ {isModalRoleOpen &&
+ (mailDomain.abilities.put || mailDomain.abilities.patch) && (
+ setIsModalRoleOpen(false)}
+ slug={mailDomain.slug}
+ />
+ )}
+ {isModalDeleteOpen && mailDomain.abilities.delete && (
+ setIsModalDeleteOpen(false)}
+ mailDomain={mailDomain}
+ />
+ )}
+ >
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesContent.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesContent.tsx
new file mode 100644
index 000000000..3d7a59a5c
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesContent.tsx
@@ -0,0 +1,66 @@
+import { Button } from '@openfun/cunningham-react';
+import { useRouter } from 'next/navigation';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, Text } from '@/components';
+import { AccessesGrid } from '@/features/mail-domains/access-management/components/AccessesGrid';
+import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.svg';
+
+import { MailDomain, Role } from '../../domains';
+
+export const AccessesContent = ({
+ mailDomain,
+ currentRole,
+}: {
+ mailDomain: MailDomain;
+ currentRole: Role;
+}) => (
+ <>
+
+
+ >
+);
+
+const TopBanner = ({ mailDomain }: { mailDomain: MailDomain }) => {
+ const router = useRouter();
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {mailDomain?.name}
+
+
+
+
+
+
+ {mailDomain?.abilities?.manage_accesses && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesGrid.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesGrid.tsx
new file mode 100644
index 000000000..d2b2014b7
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesGrid.tsx
@@ -0,0 +1,177 @@
+import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react';
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import IconUser from '@/assets/icons/icon-user.svg';
+import { Box, Card, TextErrors } from '@/components';
+import { useCunninghamTheme } from '@/cunningham';
+
+import { MailDomain, Role } from '../../domains';
+import { useMailDomainAccesses } from '../api';
+import { PAGE_SIZE } from '../conf';
+import { Access } from '../types';
+
+import { AccessAction } from './AccessAction';
+
+interface AccessesGridProps {
+ mailDomain: MailDomain;
+ currentRole: Role;
+}
+
+type SortModelItem = {
+ field: string;
+ sort: 'asc' | 'desc' | null;
+};
+
+const defaultOrderingMapping: Record = {
+ 'user.name': 'user__name',
+ 'user.email': 'user__email',
+ localizedRole: 'role',
+};
+
+/**
+ * Formats the sorting model based on a given mapping.
+ * @param {SortModelItem} sortModel The sorting model item containing field and sort direction.
+ * @param {Record} mapping The mapping object to map field names.
+ * @returns {string} The formatted sorting string.
+ * @todo same as team members grid
+ */
+function formatSortModel(
+ sortModel: SortModelItem,
+ mapping = defaultOrderingMapping,
+) {
+ const { field, sort } = sortModel;
+ const orderingField = mapping[field] || field;
+ return sort === 'desc' ? `-${orderingField}` : orderingField;
+}
+
+/**
+ * @param mailDomain
+ * @param currentRole
+ * @todo same as team members grid
+ */
+export const AccessesGrid = ({
+ mailDomain,
+ currentRole,
+}: AccessesGridProps) => {
+ const { t } = useTranslation();
+ const { colorsTokens } = useCunninghamTheme();
+ const pagination = usePagination({
+ pageSize: PAGE_SIZE,
+ });
+ const [sortModel, setSortModel] = useState([]);
+ const [accesses, setAccesses] = useState([]);
+ const { page, pageSize, setPagesCount } = pagination;
+
+ const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
+
+ const { data, isLoading, error } = useMailDomainAccesses({
+ slug: mailDomain.slug,
+ page,
+ ordering,
+ });
+
+ useEffect(() => {
+ if (isLoading) {
+ return;
+ }
+
+ const localizedRoles = {
+ [Role.ADMIN]: t('Administrator'),
+ [Role.VIEWER]: t('Viewer'),
+ [Role.OWNER]: t('Owner'),
+ };
+
+ /*
+ * Bug occurs from the Cunningham Datagrid component, when applying sorting
+ * on null values. Sanitize empty values to ensure consistent sorting functionality.
+ */
+ const accesses =
+ data?.results?.map((access) => ({
+ ...access,
+ localizedRole: localizedRoles[access.role],
+ user: {
+ ...access.user,
+ name: access.user.name,
+ email: access.user.email,
+ },
+ })) || [];
+
+ setAccesses(accesses);
+ }, [data?.results, t, isLoading]);
+
+ useEffect(() => {
+ setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
+ }, [data?.count, pageSize, setPagesCount]);
+
+ return (
+
+ {error && }
+
+
+
+
+ );
+ },
+ },
+ {
+ headerName: t('Names'),
+ field: 'user.name',
+ },
+ {
+ field: 'user.email',
+ headerName: t('Emails'),
+ },
+ {
+ field: 'localizedRole',
+ headerName: t('Roles'),
+ },
+ {
+ id: 'column-actions',
+ renderCell: ({ row }) => (
+
+ ),
+ },
+ ]}
+ rows={accesses}
+ isLoading={isLoading}
+ pagination={pagination}
+ onSortModelChange={setSortModel}
+ sortModel={sortModel}
+ />
+
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ChooseRole.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ChooseRole.tsx
new file mode 100644
index 000000000..7e1b17fea
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ChooseRole.tsx
@@ -0,0 +1,66 @@
+import { Radio, RadioGroup } from '@openfun/cunningham-react';
+import { useTranslation } from 'react-i18next';
+
+import { Role } from '../../domains';
+
+interface ChooseRoleProps {
+ availableRoles: Role[];
+ currentRole: Role;
+ disabled: boolean;
+ setRole: (role: Role) => void;
+}
+
+export const ChooseRole = ({
+ availableRoles,
+ disabled,
+ currentRole,
+ setRole,
+}: ChooseRoleProps) => {
+ const { t } = useTranslation();
+ const rolesToDisplay = Array.from(new Set([currentRole, ...availableRoles]));
+
+ return (
+
+ {rolesToDisplay?.map((role) => {
+ switch (role) {
+ case Role.VIEWER:
+ return (
+ setRole(evt.target.value as Role)}
+ defaultChecked={currentRole === Role.VIEWER}
+ disabled={disabled}
+ />
+ );
+ case Role.ADMIN:
+ return (
+ setRole(evt.target.value as Role)}
+ defaultChecked={currentRole === Role.ADMIN}
+ disabled={disabled}
+ />
+ );
+ case Role.OWNER:
+ return (
+ setRole(evt.target.value as Role)}
+ defaultChecked={currentRole === Role.OWNER}
+ disabled={disabled || currentRole !== Role.OWNER}
+ />
+ );
+ }
+ })}
+
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalDelete.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalDelete.tsx
new file mode 100644
index 000000000..e9f09d824
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalDelete.tsx
@@ -0,0 +1,145 @@
+import {
+ Button,
+ 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 { Modal } from '@/components/Modal';
+import { useCunninghamTheme } from '@/cunningham';
+
+import { MailDomain, Role } from '../../domains';
+import { useDeleteMailDomainAccess } from '../api';
+import { useWhoAmI } from '../hooks/useWhoAmI';
+import { Access } from '../types';
+
+export interface ModalDeleteProps {
+ access: Access;
+ currentRole: Role;
+ onClose: () => void;
+ mailDomain: MailDomain;
+}
+
+export const ModalDelete = ({
+ access,
+ onClose,
+ mailDomain,
+}: ModalDeleteProps) => {
+ const { toast } = useToastProvider();
+ const { colorsTokens } = useCunninghamTheme();
+ const router = useRouter();
+
+ const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
+ const isNotAllowed = isOtherOwner || isLastOwner;
+
+ const {
+ mutate: removeMailDomainAccess,
+ error: errorDeletion,
+ isError: isErrorUpdate,
+ } = useDeleteMailDomainAccess({
+ onSuccess: () => {
+ toast(
+ t('The access has been removed from the domain'),
+ VariantType.SUCCESS,
+ {
+ duration: 4000,
+ },
+ );
+
+ // If we remove ourselves, we redirect to the home page
+ // because we are no longer part of the domain
+ if (isMyself) {
+ router.push('/');
+ } else {
+ onClose();
+ }
+ },
+ });
+
+ return (
+ onClose()}>
+ {t('Cancel')}
+
+ }
+ onClose={onClose}
+ rightActions={
+
+ }
+ size={ModalSize.MEDIUM}
+ title={
+
+
+ {t('Remove this access from the domain')}
+
+
+ }
+ >
+
+
+ {t(
+ 'Are you sure you want to remove this access from the {{domain}} domain?',
+ { domain: mailDomain.name },
+ )}
+
+
+ {isErrorUpdate && (
+
+ )}
+
+ {(isLastOwner || isOtherOwner) && (
+
+ warning
+ {isLastOwner &&
+ t(
+ 'You are the last owner, you cannot be removed from your domain.',
+ )}
+ {isOtherOwner && t('You cannot remove other owner.')}
+
+ )}
+
+
+
+ {access.user.name}
+
+
+
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalRole.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalRole.tsx
new file mode 100644
index 000000000..aed667d8b
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalRole.tsx
@@ -0,0 +1,123 @@
+import {
+ Button,
+ ModalSize,
+ VariantType,
+ useToastProvider,
+} from '@openfun/cunningham-react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, Text, TextErrors } from '@/components';
+import { Modal } from '@/components/Modal';
+import { useUpdateMailDomainAccess } from '@/features/mail-domains/access-management';
+
+import { Role } from '../../domains';
+import { useWhoAmI } from '../hooks/useWhoAmI';
+import { Access } from '../types';
+
+import { ChooseRole } from './ChooseRole';
+
+interface ModalRoleProps {
+ access: Access;
+ currentRole: Role;
+ onClose: () => void;
+ slug: string;
+}
+
+export const ModalRole = ({
+ access,
+ currentRole,
+ onClose,
+ slug,
+}: ModalRoleProps) => {
+ const { t } = useTranslation();
+ const [localRole, setLocalRole] = useState(access.role);
+ const { toast } = useToastProvider();
+ const {
+ mutate: updateMailDomainAccess,
+ error: errorUpdate,
+ isError: isErrorUpdate,
+ isPending,
+ } = useUpdateMailDomainAccess({
+ 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 domain. Make another member the domain owner, before you can change your own role.',
+ )}
+ {isOtherOwner && t('You cannot update the role of other owner.')}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessAction.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessAction.test.tsx
new file mode 100644
index 000000000..059e34473
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessAction.test.tsx
@@ -0,0 +1,181 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { AppWrapper } from '@/tests/utils';
+
+import { MailDomain, Role } from '../../../domains';
+import { Access } from '../../types';
+import { AccessAction } from '../AccessAction';
+import { ModalDelete } from '../ModalDelete';
+import { ModalRole } from '../ModalRole';
+
+jest.mock('../ModalRole', () => ({
+ ModalRole: jest.fn(() => Mock ModalRole
),
+}));
+
+jest.mock('../ModalDelete', () => ({
+ ModalDelete: jest.fn(() => Mock ModalDelete
),
+}));
+
+describe('AccessAction', () => {
+ const mockMailDomain: MailDomain = {
+ id: '1-1-1-1-1',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ abilities: {
+ get: true,
+ patch: true,
+ put: true,
+ post: true,
+ delete: true,
+ manage_accesses: true,
+ },
+ };
+
+ const mockAccess: Access = {
+ id: '2-1-1-1-1',
+ role: Role.ADMIN,
+ user: {
+ id: '11',
+ name: 'username1',
+ email: 'user1@test.com',
+ },
+ can_set_role_to: [Role.VIEWER, Role.ADMIN],
+ };
+
+ const renderAccessAction = (
+ currentRole: Role = Role.ADMIN,
+ access: Access = mockAccess,
+ mailDomain = mockMailDomain,
+ ) =>
+ render(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders nothing for unauthorized roles', () => {
+ renderAccessAction(Role.VIEWER);
+
+ expect(
+ screen.queryByLabelText('Open the access options modal'),
+ ).not.toBeInTheDocument();
+
+ renderAccessAction(Role.ADMIN, { ...mockAccess, role: Role.OWNER });
+
+ expect(
+ screen.queryByLabelText('Open the access options modal'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render "Update role" button when mailDomain lacks "put" and "patch" abilities', async () => {
+ const mailDomainWithoutUpdate = {
+ ...mockMailDomain,
+ abilities: {
+ ...mockMailDomain.abilities,
+ put: false,
+ patch: false,
+ },
+ };
+
+ renderAccessAction(Role.ADMIN, mockAccess, mailDomainWithoutUpdate);
+
+ const openButton = screen.getByLabelText('Open the access options modal');
+ await userEvent.click(openButton);
+
+ expect(
+ screen.queryByLabelText(
+ 'Open the modal to update the role of this access',
+ ),
+ ).not.toBeInTheDocument();
+ });
+
+ it('opens the role update modal with correct props when "Update role" is clicked', async () => {
+ renderAccessAction();
+
+ const openButton = screen.getByLabelText('Open the access options modal');
+ await userEvent.click(openButton);
+
+ const updateRoleButton = screen.getByLabelText(
+ 'Open the modal to update the role of this access',
+ );
+ await userEvent.click(updateRoleButton);
+
+ expect(screen.getByText('Mock ModalRole')).toBeInTheDocument();
+ expect(ModalRole).toHaveBeenCalledWith(
+ expect.objectContaining({
+ access: mockAccess,
+ currentRole: Role.ADMIN,
+ slug: mockMailDomain.slug,
+ onClose: expect.any(Function),
+ }),
+ {},
+ );
+ });
+
+ it('does not render "Remove from domain" button when mailDomain lacks "delete" ability', async () => {
+ const mailDomainWithoutDelete = {
+ ...mockMailDomain,
+ abilities: {
+ ...mockMailDomain.abilities,
+ delete: false,
+ },
+ };
+
+ renderAccessAction(Role.ADMIN, mockAccess, mailDomainWithoutDelete);
+
+ const openButton = screen.getByLabelText('Open the access options modal');
+ await userEvent.click(openButton);
+
+ expect(
+ screen.queryByLabelText('Open the modal to delete this access'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('opens the delete modal with correct props when "Remove from domain" is clicked', async () => {
+ renderAccessAction();
+
+ const openButton = screen.getByLabelText('Open the access options modal');
+ await userEvent.click(openButton);
+
+ const removeButton = screen.getByLabelText(
+ 'Open the modal to delete this access',
+ );
+ await userEvent.click(removeButton);
+
+ expect(screen.getByText('Mock ModalDelete')).toBeInTheDocument();
+ expect(ModalDelete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ access: mockAccess,
+ currentRole: Role.ADMIN,
+ mailDomain: mockMailDomain,
+ onClose: expect.any(Function),
+ }),
+ {},
+ );
+ });
+
+ it('toggles the DropButton', async () => {
+ renderAccessAction();
+
+ const openButton = screen.getByLabelText('Open the access options modal');
+ expect(screen.queryByText('Update role')).toBeNull();
+
+ await userEvent.click(openButton);
+ expect(screen.getByText('Update role')).toBeInTheDocument();
+
+ // Close the dropdown
+ await userEvent.click(openButton);
+ expect(screen.queryByText('Update role')).toBeNull();
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesContent.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesContent.test.tsx
new file mode 100644
index 000000000..b0b1d62c9
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesContent.test.tsx
@@ -0,0 +1,118 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useRouter } from 'next/navigation';
+
+import { AccessesGrid } from '@/features/mail-domains/access-management';
+import { AppWrapper } from '@/tests/utils';
+
+import { MailDomain, Role } from '../../../domains';
+import { AccessesContent } from '../AccessesContent';
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}));
+
+jest.mock(
+ '@/features/mail-domains/access-management/components/AccessesGrid',
+ () => ({
+ AccessesGrid: jest.fn(() => Mock AccessesGrid
),
+ }),
+);
+
+jest.mock('@/features/mail-domains/assets/mail-domains-logo.svg', () => () => (
+
+));
+
+describe('AccessesContent', () => {
+ const mockRouterPush = jest.fn();
+
+ const mockMailDomain: MailDomain = {
+ id: '1-1-1-1-1',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ abilities: {
+ get: true,
+ patch: true,
+ put: true,
+ post: true,
+ delete: true,
+ manage_accesses: true,
+ },
+ };
+
+ const renderAccessesContent = (
+ currentRole: Role = Role.ADMIN,
+ mailDomain: MailDomain = mockMailDomain,
+ ) =>
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useRouter as jest.Mock).mockReturnValue({
+ push: mockRouterPush,
+ });
+ });
+
+ it('renders the top banner and accesses grid correctly', () => {
+ renderAccessesContent();
+
+ expect(screen.getByText(mockMailDomain.name)).toBeInTheDocument();
+ expect(screen.getByTestId('mail-domains-logo')).toBeInTheDocument();
+ expect(screen.getByText('Mock AccessesGrid')).toBeInTheDocument();
+ });
+
+ it('renders the "Manage mailboxes" button when the user has access', () => {
+ renderAccessesContent();
+
+ const manageMailboxesButton = screen.getByRole('button', {
+ name: /Manage example.com domain mailboxes/,
+ });
+
+ expect(manageMailboxesButton).toBeInTheDocument();
+
+ expect(AccessesGrid).toHaveBeenCalledWith(
+ { currentRole: Role.ADMIN, mailDomain: mockMailDomain },
+ {}, // adding this empty object is necessary to load jest context and that AccessesGrid is a mock
+ );
+ });
+
+ it('does not render the "Manage mailboxes" button if the user lacks manage_accesses ability', () => {
+ const mailDomainWithoutAccess = {
+ ...mockMailDomain,
+ abilities: {
+ ...mockMailDomain.abilities,
+ manage_accesses: false,
+ },
+ };
+
+ renderAccessesContent(Role.ADMIN, mailDomainWithoutAccess);
+
+ expect(
+ screen.queryByRole('button', {
+ name: /Manage mailboxes/i,
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('navigates to the mailboxes management page when "Manage mailboxes" is clicked', async () => {
+ renderAccessesContent();
+
+ const manageMailboxesButton = screen.getByRole('button', {
+ name: /Manage example.com domain mailboxes/,
+ });
+
+ await userEvent.click(manageMailboxesButton);
+
+ await waitFor(() => {
+ expect(mockRouterPush).toHaveBeenCalledWith(`/mail-domains/example-com/`);
+ });
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesGrid.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesGrid.test.tsx
new file mode 100644
index 000000000..68e3775d8
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesGrid.test.tsx
@@ -0,0 +1,146 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+
+import { AppWrapper } from '@/tests/utils';
+
+import { MailDomain, Role } from '../../../domains';
+import { Access } from '../../types';
+import { AccessesGrid } from '../AccessesGrid';
+
+jest.mock(
+ '@/features/mail-domains/access-management/components/AccessAction',
+ () => ({
+ AccessAction: jest.fn(() => Mock AccessAction
),
+ }),
+);
+
+jest.mock('@/assets/icons/icon-user.svg', () => () => (
+
+));
+
+const mockMailDomain: MailDomain = {
+ id: '1-1-1-1-1',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ abilities: {
+ manage_accesses: true,
+ get: true,
+ patch: true,
+ put: true,
+ post: true,
+ delete: false,
+ },
+};
+
+const mockAccess: Access = {
+ id: '2-1-1-1-1',
+ role: Role.ADMIN,
+ user: {
+ id: '3-1-1-1-1',
+ name: 'username1',
+ email: 'user1@test.com',
+ },
+ can_set_role_to: [Role.VIEWER, Role.ADMIN],
+};
+
+const mockAccessCreationResponse = {
+ count: 2,
+ results: [
+ mockAccess,
+ {
+ id: '1-1-1-1-2',
+ role: Role.VIEWER,
+ user: { id: '22', name: 'username2', email: 'user2@test.com' },
+ can_set_role_to: [Role.VIEWER],
+ },
+ ],
+};
+
+describe('AccessesGrid', () => {
+ const renderAccessesGrid = (role: Role = Role.ADMIN) =>
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('renders the grid with loading state', async () => {
+ fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
+ status: 200,
+ body: mockAccessCreationResponse,
+ });
+
+ renderAccessesGrid();
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(screen.queryByRole('status')).not.toBeInTheDocument(),
+ );
+
+ expect(screen.getByText('username1')).toBeInTheDocument();
+ expect(screen.getByText('username2')).toBeInTheDocument();
+ });
+
+ it('renders an error message if the API call fails', async () => {
+ fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
+ status: 500,
+ body: { cause: ['Internal server error'] },
+ });
+
+ renderAccessesGrid();
+
+ expect(await screen.findByText('Internal server error')).toBeVisible();
+ });
+
+ it('applies sorting when a column header is clicked', async () => {
+ fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
+ status: 200,
+ body: mockAccessCreationResponse,
+ });
+
+ renderAccessesGrid();
+
+ await screen.findByText('username1');
+
+ fetchMock.getOnce(
+ 'end:/mail-domains/example-com/accesses/?page=1&ordering=user__name',
+ {
+ status: 200,
+ body: mockAccessCreationResponse,
+ },
+ );
+
+ const nameHeader = screen.getByText('Names');
+ await userEvent.click(nameHeader);
+
+ // First load call, then sorting call
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(2));
+ });
+
+ it('displays the correct columns and rows in the grid', async () => {
+ fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
+ status: 200,
+ body: mockAccessCreationResponse,
+ });
+
+ renderAccessesGrid();
+
+ // Waiting for the rows to render
+ await screen.findByText('Names');
+
+ expect(screen.getByText('Emails')).toBeInTheDocument();
+ expect(screen.getByText('Roles')).toBeInTheDocument();
+ expect(screen.getByText('username1')).toBeInTheDocument();
+ expect(screen.getByText('user1@test.com')).toBeInTheDocument();
+ expect(screen.getByText('Administrator')).toBeInTheDocument();
+
+ expect(screen.getAllByText('Mock AccessAction')).toHaveLength(2);
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ChooseRole.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ChooseRole.test.tsx
new file mode 100644
index 000000000..57d92d147
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ChooseRole.test.tsx
@@ -0,0 +1,122 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { AppWrapper } from '@/tests/utils';
+
+import { Role } from '../../../domains';
+import { ChooseRole } from '../ChooseRole';
+
+describe('ChooseRole', () => {
+ const mockSetRole = jest.fn();
+
+ const renderChooseRole = (
+ props: Partial> = {},
+ ) => {
+ const defaultProps = {
+ availableRoles: [Role.VIEWER, Role.ADMIN],
+ currentRole: Role.ADMIN,
+ disabled: false,
+ setRole: mockSetRole,
+ ...props,
+ };
+
+ return render(, { wrapper: AppWrapper });
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders available roles correctly', () => {
+ renderChooseRole();
+
+ expect(screen.getByLabelText('Viewer')).toBeInTheDocument();
+ expect(screen.getByLabelText('Administrator')).toBeInTheDocument();
+ });
+
+ it('sets default role checked correctly', () => {
+ renderChooseRole({ currentRole: Role.ADMIN });
+
+ const adminRadio: HTMLInputElement = screen.getByLabelText('Administrator');
+ const viewerRadio: HTMLInputElement = screen.getByLabelText('Viewer');
+
+ expect(adminRadio).toBeChecked();
+ expect(viewerRadio).not.toBeChecked();
+ });
+
+ it('calls setRole when a new role is selected', async () => {
+ const user = userEvent.setup();
+
+ renderChooseRole();
+
+ await user.click(screen.getByLabelText('Viewer'));
+
+ await waitFor(() => {
+ expect(mockSetRole).toHaveBeenCalledWith(Role.VIEWER);
+ });
+ });
+
+ it('disables radio buttons when disabled prop is true', () => {
+ renderChooseRole({ disabled: true });
+
+ const viewerRadio: HTMLInputElement = screen.getByLabelText('Viewer');
+ const adminRadio: HTMLInputElement = screen.getByLabelText('Administrator');
+
+ expect(viewerRadio).toBeDisabled();
+ expect(adminRadio).toBeDisabled();
+ });
+
+ it('disables owner radio button if current role is not owner', () => {
+ renderChooseRole({
+ availableRoles: [Role.VIEWER, Role.ADMIN, Role.OWNER],
+ currentRole: Role.ADMIN,
+ });
+
+ const ownerRadio = screen.getByLabelText('Owner');
+
+ expect(ownerRadio).toBeDisabled();
+ });
+
+ it('removes duplicates from availableRoles', () => {
+ renderChooseRole({
+ availableRoles: [Role.VIEWER, Role.ADMIN, Role.VIEWER],
+ currentRole: Role.ADMIN,
+ });
+
+ const radios = screen.getAllByRole('radio');
+ expect(radios.length).toBe(2); // Only two unique roles should be rendered
+ });
+
+ it('renders and checks owner role correctly when currentRole is owner', () => {
+ renderChooseRole({
+ currentRole: Role.OWNER,
+ availableRoles: [Role.OWNER, Role.VIEWER, Role.ADMIN],
+ });
+
+ const ownerRadio: HTMLInputElement = screen.getByLabelText('Owner');
+
+ expect(ownerRadio).toBeInTheDocument();
+ expect(ownerRadio).toBeChecked();
+ });
+
+ it('renders no roles if availableRoles is empty', () => {
+ renderChooseRole({
+ availableRoles: [],
+ currentRole: Role.ADMIN,
+ });
+
+ const radios = screen.queryAllByRole('radio');
+ expect(radios.length).toBe(1); // Only the current role should be rendered
+ });
+
+ it.failing('sets aria-checked attribute correctly for selected roles', () => {
+ renderChooseRole({ currentRole: Role.ADMIN });
+
+ const adminRadio = screen.getByLabelText('Administrator');
+ const viewerRadio = screen.getByLabelText('Viewer');
+
+ expect(adminRadio).toHaveAttribute('aria-checked', 'true');
+ expect(viewerRadio).toHaveAttribute('aria-checked', 'false');
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalDelete.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalDelete.test.tsx
new file mode 100644
index 000000000..e24d169db
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalDelete.test.tsx
@@ -0,0 +1,228 @@
+import { VariantType, useToastProvider } from '@openfun/cunningham-react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+import { useRouter } from 'next/navigation';
+
+import { Access } from '@/features/mail-domains/access-management';
+import { AppWrapper } from '@/tests/utils';
+
+import { MailDomain, Role } from '../../../domains';
+import { useWhoAmI } from '../../hooks/useWhoAmI';
+import { ModalDelete, ModalDeleteProps } from '../ModalDelete';
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}));
+
+jest.mock('@openfun/cunningham-react', () => ({
+ ...jest.requireActual('@openfun/cunningham-react'),
+ useToastProvider: jest.fn(),
+}));
+
+jest.mock('../../hooks/useWhoAmI', () => ({
+ useWhoAmI: jest.fn(),
+}));
+
+describe('ModalDelete', () => {
+ const mockRouterPush = jest.fn();
+ const mockClose = jest.fn();
+ const mockToast = jest.fn();
+
+ const mockMailDomain: MailDomain = {
+ id: '1-1-1-1-1',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ abilities: {
+ get: true,
+ patch: true,
+ put: true,
+ post: true,
+ delete: true,
+ manage_accesses: true,
+ },
+ };
+
+ const mockAccess: Access = {
+ id: '2-1-1-1-1',
+ user: { id: '3-1-1-1-1', name: 'username1', email: 'user1@test.com' },
+ role: Role.ADMIN,
+ can_set_role_to: [Role.ADMIN, Role.VIEWER],
+ };
+
+ const renderModalDelete = (props: Partial = {}) =>
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ fetchMock.restore();
+ (useRouter as jest.Mock).mockReturnValue({
+ push: mockRouterPush,
+ });
+ (useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast });
+ (useWhoAmI as jest.Mock).mockReturnValue({
+ isMyself: false,
+ isLastOwner: false,
+ isOtherOwner: false,
+ });
+ });
+
+ it('renders the modal with the correct content', () => {
+ renderModalDelete();
+
+ expect(
+ screen.getByText('Remove this access from the domain'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Are you sure you want to remove this access from the example.com domain?',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText('username1')).toBeInTheDocument();
+ });
+
+ it('calls onClose when Cancel is clicked', async () => {
+ renderModalDelete();
+
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ await userEvent.click(cancelButton);
+
+ expect(mockClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends a delete request when "Remove from the domain" is clicked', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
+ status: 204,
+ });
+
+ renderModalDelete();
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from the domain',
+ });
+ await userEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(fetchMock.calls().length).toBe(1);
+ });
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-com/accesses/2-1-1-1-1/',
+ );
+ });
+
+ it('displays error message when API call fails', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
+ status: 500,
+ body: { cause: ['Failed to delete access'] },
+ });
+
+ renderModalDelete();
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from the domain',
+ });
+ await userEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to delete access')).toBeInTheDocument();
+ });
+ });
+
+ it('disables the remove button if the user is the last owner', () => {
+ (useWhoAmI as jest.Mock).mockReturnValue({
+ isMyself: false,
+ isLastOwner: true,
+ isOtherOwner: false,
+ });
+
+ renderModalDelete();
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from the domain',
+ });
+ expect(removeButton).toBeDisabled();
+ expect(
+ screen.getByText(
+ 'You are the last owner, you cannot be removed from your domain.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('disables the remove button if the user is not allowed to remove another owner', () => {
+ (useWhoAmI as jest.Mock).mockReturnValue({
+ isMyself: false,
+ isLastOwner: false,
+ isOtherOwner: true,
+ });
+
+ renderModalDelete();
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from the domain',
+ });
+ expect(removeButton).toBeDisabled();
+ expect(
+ screen.getByText('You cannot remove other owner.'),
+ ).toBeInTheDocument();
+ });
+
+ it('redirects to home page if user removes themselves', async () => {
+ (useWhoAmI as jest.Mock).mockReturnValue({
+ isMyself: true,
+ isLastOwner: false,
+ isOtherOwner: false,
+ });
+
+ fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
+ status: 204,
+ });
+
+ renderModalDelete();
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from the domain',
+ });
+ await userEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(mockRouterPush).toHaveBeenCalledWith('/');
+ });
+ });
+
+ it('shows success toast and calls onClose after successful deletion', async () => {
+ fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
+ status: 204,
+ });
+
+ renderModalDelete();
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from the domain',
+ });
+ await userEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(fetchMock.calls().length).toBe(1);
+ });
+ expect(mockToast).toHaveBeenCalledWith(
+ 'The access has been removed from the domain',
+ VariantType.SUCCESS,
+ { duration: 4000 },
+ );
+ expect(mockClose).toHaveBeenCalled();
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalRole.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalRole.test.tsx
new file mode 100644
index 000000000..7c42f39bc
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalRole.test.tsx
@@ -0,0 +1,166 @@
+import { VariantType, useToastProvider } from '@openfun/cunningham-react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+import React from 'react';
+
+import { AppWrapper } from '@/tests/utils';
+
+import { Role } from '../../../domains';
+import { useWhoAmI } from '../../hooks/useWhoAmI';
+import { Access } from '../../types';
+import { ModalRole } from '../ModalRole';
+
+jest.mock('@openfun/cunningham-react', () => ({
+ ...jest.requireActual('@openfun/cunningham-react'),
+ useToastProvider: jest.fn(),
+}));
+jest.mock('../../hooks/useWhoAmI');
+
+describe('ModalRole', () => {
+ const access: Access = {
+ id: '1-1-1-1-1-1',
+ role: Role.ADMIN,
+ user: {
+ id: '2-1-1-1-1-1',
+ name: 'username1',
+ email: 'user1@test.com',
+ },
+ can_set_role_to: [Role.VIEWER, Role.ADMIN],
+ };
+
+ const mockOnClose = jest.fn();
+ const mockToast = jest.fn();
+
+ const renderModalRole = (
+ isLastOwner = false,
+ isOtherOwner = false,
+ props?: Partial>,
+ ) => {
+ (useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast });
+ (useWhoAmI as jest.Mock).mockReturnValue({
+ isLastOwner,
+ isOtherOwner,
+ });
+
+ return render(
+ ,
+ { wrapper: AppWrapper },
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ fetchMock.restore();
+ });
+
+ it('renders the modal with all elements', () => {
+ renderModalRole();
+
+ expect(screen.getByText('Update the role')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /Validate/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByLabelText('Radio buttons to update the roles'),
+ ).toBeInTheDocument();
+ });
+
+ it('calls the close function when Cancel is clicked', async () => {
+ renderModalRole();
+
+ const cancelButton = screen.getByRole('button', { name: /Cancel/i });
+ await userEvent.click(cancelButton);
+
+ await waitFor(() => {
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('updates the role and closes the modal when Validate is clicked', async () => {
+ fetchMock.patch(`end:mail-domains/domain-slug/accesses/1-1-1-1-1-1/`, {
+ status: 200,
+ body: {
+ id: '1-1-1-1-1-1',
+ role: Role.VIEWER,
+ },
+ });
+
+ renderModalRole();
+
+ const validateButton = screen.getByRole('button', { name: /Validate/i });
+ await userEvent.click(validateButton);
+
+ await waitFor(() => {
+ expect(fetchMock.calls().length).toBe(1);
+ });
+ expect(fetchMock.lastCall()?.[0]).toContain(
+ '/mail-domains/domain-slug/accesses/1-1-1-1-1-1/',
+ );
+
+ await waitFor(() => {
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockToast).toHaveBeenCalledWith(
+ 'The role has been updated',
+ VariantType.SUCCESS,
+ { duration: 4000 },
+ );
+ });
+
+ it('disables the Validate button if the user is the last owner', () => {
+ renderModalRole(true, false); // isLastOwner = true, isOtherOwner = false
+
+ const validateButton = screen.getByRole('button', { name: /Validate/i });
+ expect(validateButton).toBeDisabled();
+ expect(
+ screen.getByText(/You are the sole owner of this domain/i),
+ ).toBeInTheDocument();
+ });
+
+ it('disables the Validate button if the user is another owner', () => {
+ renderModalRole(false, true); // isLastOwner = false, isOtherOwner = true
+
+ const validateButton = screen.getByRole('button', { name: /Validate/i });
+ expect(validateButton).toBeDisabled();
+ expect(
+ screen.getByText(/You cannot update the role of other owner/i),
+ ).toBeInTheDocument();
+ });
+
+ it('shows error message when update fails', async () => {
+ fetchMock.patch(`end:mail-domains/domain-slug/accesses/1-1-1-1-1-1/`, {
+ status: 400,
+ body: {
+ cause: ['Error updating role'],
+ },
+ });
+
+ renderModalRole();
+
+ const validateButton = screen.getByRole('button', { name: /Validate/i });
+ await userEvent.click(validateButton);
+
+ await waitFor(() => {
+ expect(fetchMock.calls().length).toBe(1);
+ });
+
+ expect(screen.getByText('Error updating role')).toBeInTheDocument();
+ });
+
+ it('displays the available roles and ensures no duplicates', () => {
+ renderModalRole();
+
+ const radioButtons = screen.getAllByRole('radio');
+ expect(radioButtons.length).toBe(2); // Only two roles: Viewer and Admin
+ expect(screen.getByLabelText('Administrator')).toBeInTheDocument();
+ expect(screen.getByLabelText('Viewer')).toBeInTheDocument();
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/index.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/index.ts
new file mode 100644
index 000000000..22d144167
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/index.ts
@@ -0,0 +1,5 @@
+export * from './AccessesContent';
+export * from './AccessesGrid';
+export * from './ChooseRole';
+export * from './ModalRole';
+export * from './ModalDelete';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/conf.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/conf.ts
new file mode 100644
index 000000000..bfab90671
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/conf.ts
@@ -0,0 +1 @@
+export const PAGE_SIZE = 20;
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/__tests__/useWhoAmI.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/__tests__/useWhoAmI.test.tsx
new file mode 100644
index 000000000..e2f4f5a03
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/__tests__/useWhoAmI.test.tsx
@@ -0,0 +1,86 @@
+import { renderHook } from '@testing-library/react';
+
+import { useAuthStore } from '@/core/auth/useAuthStore';
+
+import { Role } from '../../../domains';
+import { useWhoAmI } from '../../hooks/useWhoAmI';
+import { Access } from '../../types';
+
+jest.mock('@/core/auth/useAuthStore');
+
+const mockAccess: Access = {
+ id: '1-1-1-1-1',
+ user: {
+ id: '2-1-1-1-1',
+ name: 'User One',
+ email: 'user1@example.com',
+ },
+ role: Role.ADMIN,
+ can_set_role_to: [Role.VIEWER, Role.ADMIN],
+};
+
+describe('useWhoAmI', () => {
+ beforeEach(() => {
+ (useAuthStore as unknown as jest.Mock).mockReturnValue({
+ authenticated: true,
+ userData: {
+ id: '2-1-1-1-1',
+ name: 'Current User',
+ email: 'currentuser@example.com',
+ },
+ });
+ });
+
+ const renderUseWhoAmI = (access: Access) =>
+ renderHook(() => useWhoAmI(access));
+
+ it('identifies if the current user is themselves', () => {
+ const { result } = renderUseWhoAmI(mockAccess);
+ expect(result.current.isMyself).toBeTruthy();
+ });
+
+ it('identifies if the current user is not themselves', () => {
+ const { result } = renderUseWhoAmI({
+ ...mockAccess,
+ user: { ...mockAccess.user, id: '2-1-1-1-2' },
+ });
+ expect(result.current.isMyself).toBeFalsy();
+ });
+
+ it('identifies if the current user is the last owner', () => {
+ const accessAsLastOwner = {
+ ...mockAccess,
+ role: Role.OWNER,
+ can_set_role_to: [],
+ };
+ const { result } = renderUseWhoAmI(accessAsLastOwner);
+ expect(result.current.isLastOwner).toBeTruthy();
+ });
+
+ it('identifies if the current user is not the last owner', () => {
+ const accessAsNonOwner = { ...mockAccess, role: Role.ADMIN };
+ const { result } = renderUseWhoAmI(accessAsNonOwner);
+ expect(result.current.isLastOwner).toBeFalsy();
+ });
+
+ it('identifies if the current user is another owner', () => {
+ const accessOfOtherOwner = {
+ ...mockAccess,
+ role: Role.OWNER,
+ user: { ...mockAccess.user, id: '2-1-1-1-2' },
+ };
+
+ const { result } = renderUseWhoAmI(accessOfOtherOwner);
+ expect(result.current.isOtherOwner).toBeTruthy();
+ });
+
+ it('identifies if the current user is not another owner', () => {
+ const nonOwnerAccess = {
+ ...mockAccess,
+ role: Role.ADMIN,
+ user: { ...mockAccess.user, id: '2-1-1-1-2' },
+ };
+ const { result } = renderUseWhoAmI(nonOwnerAccess);
+ expect(result.current.isOtherOwner).toBeFalsy();
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/useWhoAmI.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/useWhoAmI.tsx
new file mode 100644
index 000000000..41827c0eb
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/useWhoAmI.tsx
@@ -0,0 +1,22 @@
+import { useAuthStore } from '@/core/auth';
+
+import { Role } from '../../domains/types';
+import { Access } from '../types';
+
+export const useWhoAmI = (access: Access) => {
+ const { userData } = useAuthStore();
+
+ const isMyself = userData?.id === access.user.id;
+ const rolesAllowed = access.can_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,
+ };
+};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/index.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/index.ts
new file mode 100644
index 000000000..314dad0cd
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/index.ts
@@ -0,0 +1,3 @@
+export * from './api';
+export * from './components';
+export * from './types';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/types.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/types.ts
new file mode 100644
index 000000000..4e0bc16ae
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/types.ts
@@ -0,0 +1,12 @@
+import { UUID } from 'crypto';
+
+import { User } from '@/core/auth';
+
+import { Role } from '../domains/types';
+
+export interface Access {
+ id: UUID;
+ role: Role;
+ user: User;
+ can_set_role_to: Role[];
+}
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/index.ts b/src/frontend/apps/desk/src/features/mail-domains/components/panel/index.ts
deleted file mode 100644
index 8960d84f6..000000000
--- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Panel';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalAddMailDomain.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/__tests__/ModalAddMailDomain.test.tsx
similarity index 99%
rename from src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalAddMailDomain.test.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/__tests__/ModalAddMailDomain.test.tsx
index e585ba930..f09c6156d 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalAddMailDomain.test.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/__tests__/ModalAddMailDomain.test.tsx
@@ -5,7 +5,7 @@ import React from 'react';
import { AppWrapper } from '@/tests/utils';
-import { ModalAddMailDomain } from '../ModalAddMailDomain';
+import { ModalAddMailDomain } from '../components';
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/index.tsx
similarity index 60%
rename from src/frontend/apps/desk/src/features/mail-domains/api/index.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/index.tsx
index 61a928cca..dc54b8a55 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/api/index.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/index.tsx
@@ -1,5 +1,3 @@
export * from './useMailDomains';
export * from './useMailDomain';
-export * from './useCreateMailbox';
-export * from './useMailboxes';
export * from './useAddMailDomain';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useAddMailDomain.tsx
similarity index 95%
rename from src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/useAddMailDomain.tsx
index 60c81c501..c76d3b648 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useAddMailDomain.tsx
@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
-import { MailDomain } from '@/features/mail-domains';
+
+import { MailDomain } from '../types';
import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomain.tsx
similarity index 95%
rename from src/frontend/apps/desk/src/features/mail-domains/api/useMailDomain.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomain.tsx
index 95ef86a80..a07437fab 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomain.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomain.tsx
@@ -25,7 +25,7 @@ export const getMailDomain = async ({
return response.json() as Promise;
};
-const KEY_MAIL_DOMAIN = 'mail-domain';
+export const KEY_MAIL_DOMAIN = 'mail-domain';
export function useMailDomain(
param: MailDomainParams,
diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomains.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomains.tsx
similarity index 96%
rename from src/frontend/apps/desk/src/features/mail-domains/api/useMailDomains.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomains.tsx
index 08ca73020..0a491b9c1 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomains.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomains.tsx
@@ -6,7 +6,8 @@ import {
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
-import { MailDomain } from '@/features/mail-domains/types';
+
+import { MailDomain } from '../types';
type MailDomainsResponse = APIList;
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsLayout.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainsLayout.tsx
similarity index 89%
rename from src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsLayout.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainsLayout.tsx
index 7d1e14acc..b68405e11 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsLayout.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainsLayout.tsx
@@ -3,7 +3,8 @@ import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { MainLayout } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
-import { Panel } from '@/features/mail-domains/components/panel';
+
+import { Panel } from './panel';
export function MailDomainsLayout({ children }: PropsWithChildren) {
const { colorsTokens } = useCunninghamTheme();
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/ModalAddMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/ModalAddMailDomain.tsx
similarity index 97%
rename from src/frontend/apps/desk/src/features/mail-domains/components/ModalAddMailDomain.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/ModalAddMailDomain.tsx
index f89679115..62f07c4e9 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/ModalAddMailDomain.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/ModalAddMailDomain.tsx
@@ -9,9 +9,9 @@ import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
-import { useAddMailDomain } from '@/features/mail-domains';
-import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
+import { default as MailDomainsLogo } from '../../assets/mail-domains-logo.svg';
+import { useAddMailDomain } from '../api';
const FORM_ID = 'form-add-mail-domain';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/domains/components/index.ts b/src/frontend/apps/desk/src/features/mail-domains/domains/components/index.ts
new file mode 100644
index 000000000..18a8a4915
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/index.ts
@@ -0,0 +1,3 @@
+export * from './ModalAddMailDomain';
+export * from './MailDomainsLayout';
+export * from './panel';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/ItemList.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/ItemList.tsx
similarity index 91%
rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/ItemList.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/ItemList.tsx
index 184559fe0..43447185d 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/ItemList.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/ItemList.tsx
@@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { InfiniteScroll } from '@/components/InfiniteScroll';
-import { MailDomain } from '@/features/mail-domains';
-import { useMailDomains } from '@/features/mail-domains/api/useMailDomains';
-import { useMailDomainsStore } from '@/features/mail-domains/store/useMailDomainsStore';
+import {
+ MailDomain,
+ useMailDomains,
+ useMailDomainsStore,
+} from '@/features/mail-domains/domains';
import { PanelMailDomains } from './PanelItem';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/Panel.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/Panel.tsx
similarity index 98%
rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/Panel.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/Panel.tsx
index 44e67d27b..2ce61f6ef 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/Panel.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/Panel.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import IconOpenClose from '@/assets/icons/icon-open-close.svg';
import { Box, BoxButton, Text } from '@/components';
-import { useConfigStore } from '@/core/';
+import { useConfigStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { ItemList } from './ItemList';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelActions.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelActions.tsx
similarity index 90%
rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelActions.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelActions.tsx
index d04b632ed..2edb32ebc 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelActions.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelActions.tsx
@@ -5,8 +5,10 @@ import IconAdd from '@/assets/icons/icon-add.svg';
import IconSort from '@/assets/icons/icon-sort.svg';
import { Box, BoxButton, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
-import { EnumMailDomainsOrdering } from '@/features/mail-domains';
-import { useMailDomainsStore } from '@/features/mail-domains/store/useMailDomainsStore';
+import {
+ EnumMailDomainsOrdering,
+ useMailDomainsStore,
+} from '@/features/mail-domains/domains';
export const PanelActions = () => {
const { t } = useTranslation();
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelItem.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelItem.tsx
similarity index 98%
rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelItem.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelItem.tsx
index 1065ce4f4..4f255e896 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelItem.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelItem.tsx
@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
-import { MailDomain } from '@/features/mail-domains';
import IconMailDomains from '@/features/mail-domains/assets/icon-mail-domains.svg';
+import { MailDomain } from '../../index';
+
interface MailDomainProps {
mailDomain: MailDomain;
}
diff --git a/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/index.ts b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/index.ts
new file mode 100644
index 000000000..63813bbb4
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/index.ts
@@ -0,0 +1,4 @@
+export * from './Panel';
+export * from './ItemList';
+export * from './PanelActions';
+export * from './PanelItem';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/index.tsx
similarity index 100%
rename from src/frontend/apps/desk/src/features/mail-domains/index.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/index.tsx
index a04223573..86ad49e7c 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/index.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/index.tsx
@@ -1,4 +1,4 @@
export * from './components';
-export * from './types';
export * from './api';
export * from './store';
+export * from './types';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/store/index.ts b/src/frontend/apps/desk/src/features/mail-domains/domains/store/index.ts
similarity index 100%
rename from src/frontend/apps/desk/src/features/mail-domains/store/index.ts
rename to src/frontend/apps/desk/src/features/mail-domains/domains/store/index.ts
diff --git a/src/frontend/apps/desk/src/features/mail-domains/store/useMailDomainsStore.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/store/useMailDomainsStore.tsx
similarity index 86%
rename from src/frontend/apps/desk/src/features/mail-domains/store/useMailDomainsStore.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/store/useMailDomainsStore.tsx
index dc6e195b5..0b27cd2b0 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/store/useMailDomainsStore.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/store/useMailDomainsStore.tsx
@@ -1,6 +1,6 @@
import { create } from 'zustand';
-import { EnumMailDomainsOrdering } from '../api/useMailDomains';
+import { EnumMailDomainsOrdering } from '@/features/mail-domains/domains/api';
interface MailDomainsStore {
ordering: EnumMailDomainsOrdering;
diff --git a/src/frontend/apps/desk/src/features/mail-domains/types.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/types.ts
similarity index 71%
rename from src/frontend/apps/desk/src/features/mail-domains/types.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/domains/types.ts
index 039c57d61..13b9131fd 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/types.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/domains/types.ts
@@ -1,5 +1,4 @@
import { UUID } from 'crypto';
-
export interface MailDomain {
id: UUID;
name: string;
@@ -17,10 +16,8 @@ export interface MailDomain {
};
}
-export interface MailDomainMailbox {
- id: UUID;
- local_part: string;
- first_name: string;
- last_name: string;
- secondary_email: string;
+export enum Role {
+ ADMIN = 'administrator',
+ OWNER = 'owner',
+ VIEWER = 'viewer',
}
diff --git a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/index.tsx
new file mode 100644
index 000000000..f394a921d
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/index.tsx
@@ -0,0 +1,2 @@
+export * from './useCreateMailbox';
+export * from './useMailboxes';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useCreateMailbox.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useCreateMailbox.tsx
similarity index 100%
rename from src/frontend/apps/desk/src/features/mail-domains/api/useCreateMailbox.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useCreateMailbox.tsx
diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useMailboxes.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useMailboxes.tsx
similarity index 100%
rename from src/frontend/apps/desk/src/features/mail-domains/api/useMailboxes.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useMailboxes.tsx
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsContent.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/MailDomainsContent.tsx
similarity index 84%
rename from src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsContent.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/MailDomainsContent.tsx
index 186c2a4bf..71edf2175 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsContent.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/MailDomainsContent.tsx
@@ -9,17 +9,18 @@ import {
VariantType,
usePagination,
} from '@openfun/cunningham-react';
+import { useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors, TextStyled } from '@/components';
+import { ModalCreateMailbox } from '@/features/mail-domains/mailboxes';
+import { default as MailDomainsLogo } from '../../assets/mail-domains-logo.svg';
+import { PAGE_SIZE } from '../../conf';
+import { MailDomain } from '../../domains/types';
import { useMailboxes } from '../api/useMailboxes';
-import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
-import { PAGE_SIZE } from '../conf';
-import { MailDomain, MailDomainMailbox } from '../types';
-
-import { ModalCreateMailbox } from './ModalCreateMailbox';
+import { MailDomainMailbox } from '../types';
export type ViewMailbox = {
name: string;
@@ -102,6 +103,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
+ aria-label={t('Mailboxes list card')}
>
{error && }
@@ -151,6 +153,7 @@ const TopBanner = ({
mailDomain: MailDomain;
showMailBoxCreationForm: (value: boolean) => void;
}) => {
+ const router = useRouter();
const { t } = useTranslation();
return (
@@ -165,7 +168,7 @@ const TopBanner = ({
$gap="2.25rem"
$justify="space-between"
>
-
+
{mailDomain?.name}
@@ -176,9 +179,22 @@ const TopBanner = ({
- {mailDomain?.abilities.post && (
-
-
+
+
+ {mailDomain?.abilities?.manage_accesses && (
+
+ )}
+ {mailDomain?.abilities.post && (
-
+ )}
- )}
+
);
};
@@ -204,7 +220,7 @@ const AlertStatus = ({ status }: { status: MailDomain['status'] }) => {
return {
variant: VariantType.WARNING,
message: t(
- 'Your domain name is being validated. ' +
+ 'Your domain name is being validated. ' +
'You will not be able to create mailboxes until your domain name has been validated by our team.',
),
};
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/ModalCreateMailbox.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/ModalCreateMailbox.tsx
similarity index 99%
rename from src/frontend/apps/desk/src/features/mail-domains/components/ModalCreateMailbox.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/ModalCreateMailbox.tsx
index deafe7eaf..0fe106179 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/ModalCreateMailbox.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/ModalCreateMailbox.tsx
@@ -21,8 +21,8 @@ import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
+import { MailDomain } from '../../domains/types';
import { CreateMailboxParams, useCreateMailbox } from '../api';
-import { MailDomain } from '../types';
const FORM_ID: string = 'form-create-mailbox';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/MailDomainsContent.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/MailDomainsContent.test.tsx
new file mode 100644
index 000000000..bb928ba03
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/MailDomainsContent.test.tsx
@@ -0,0 +1,278 @@
+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 React from 'react';
+
+import { AppWrapper } from '@/tests/utils';
+
+import { MailDomain } from '../../../domains/types';
+import { MailDomainsContent } from '../MailDomainsContent';
+
+const mockMailDomain: MailDomain = {
+ id: '456ac6ca-0402-4615-8005-69bc1efde43f',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ abilities: {
+ get: true,
+ patch: true,
+ put: true,
+ post: true,
+ delete: true,
+ manage_accesses: true,
+ },
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+};
+
+const mockMailDomainAsViewer: MailDomain = {
+ id: '456ac6ca-0402-4615-8005-69bc1efde43f',
+ name: 'example.com',
+ slug: 'example-com',
+ status: 'enabled',
+ abilities: {
+ get: true,
+ patch: false,
+ put: false,
+ post: false,
+ delete: false,
+ manage_accesses: false,
+ },
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+};
+
+const mockMailboxes = [
+ {
+ id: '1',
+ first_name: 'John',
+ last_name: 'Doe',
+ local_part: 'john.doe',
+ },
+ {
+ id: '2',
+ first_name: 'Jane',
+ last_name: 'Smith',
+ local_part: 'jane.smith',
+ },
+];
+
+const mockPush = jest.fn();
+const mockedUseRouter = jest.fn().mockReturnValue({
+ push: mockPush,
+});
+
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: () => mockedUseRouter(),
+}));
+
+describe('MailDomainsContent', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ it('renders with no mailboxes and displays empty placeholder', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 0,
+ results: [],
+ });
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+
+ expect(
+ await screen.findByText('No mail box was created with this mail domain.'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders mailboxes and displays them correctly', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 2,
+ results: mockMailboxes,
+ });
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ });
+ expect(screen.getByText('jane.smith@example.com')).toBeInTheDocument();
+ });
+
+ it('handles sorting by name and email', async () => {
+ const sortedByName = [...mockMailboxes].sort((a, b) =>
+ a.first_name.localeCompare(b.first_name),
+ );
+ const sortedByEmail = [...mockMailboxes].sort((a, b) =>
+ a.local_part.localeCompare(b.local_part),
+ );
+
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 2,
+ results: mockMailboxes,
+ });
+
+ fetchMock.get(
+ 'end:/mail-domains/example-com/mailboxes/?page=1&ordering=name',
+ {
+ count: 2,
+ results: sortedByName,
+ },
+ );
+
+ fetchMock.get(
+ 'end:/mail-domains/example-com/mailboxes/?page=1&ordering=local_part',
+ {
+ count: 2,
+ results: sortedByEmail,
+ },
+ );
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ // Sorting by name
+ await waitFor(async () => {
+ await userEvent.click(screen.getByRole('button', { name: 'Names' }));
+ });
+
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-com/mailboxes/?page=1&ordering=name',
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ });
+
+ // Sorting by email
+ await waitFor(async () => {
+ await userEvent.click(screen.getByRole('button', { name: 'Emails' }));
+ });
+
+ expect(fetchMock.lastUrl()).toContain(
+ '/mail-domains/example-com/mailboxes/?page=1&ordering=local_part',
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('john.doe@example.com')).toBeInTheDocument();
+ });
+ });
+
+ it('opens the create mailbox modal when button is clicked by granted user', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 0,
+ results: [],
+ });
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ await waitFor(async () => {
+ await userEvent.click(screen.getByText('Create a mailbox'));
+ });
+
+ await waitFor(async () => {
+ expect(
+ await screen.findByTitle('Mailbox creation form'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('redirects to accesses management page when button is clicked by granted user', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 0,
+ results: [],
+ });
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ await waitFor(async () => {
+ await userEvent.click(screen.getByText('Manage accesses'));
+ });
+
+ expect(mockPush).toHaveBeenCalledWith(
+ '/mail-domains/example-com/accesses/',
+ );
+ });
+
+ it('displays the correct alert based on mail domain status', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 0,
+ results: [],
+ });
+
+ const statuses = [
+ {
+ status: 'pending',
+ regex: /Your domain name is being validated/,
+ },
+ {
+ status: 'disabled',
+ regex:
+ /This domain name is deactivated. No new mailboxes can be created/,
+ },
+ {
+ status: 'failed',
+ regex: /The domain name encounters an error/,
+ },
+ ];
+
+ for (const { status, regex } of statuses) {
+ const updatedMailDomain = { ...mockMailDomain, status } as MailDomain;
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(regex)).toBeInTheDocument();
+ });
+ }
+ });
+
+ it('handles API errors and displays the error message', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ status: 500,
+ body: {
+ cause: 'An unexpected error occurred.',
+ },
+ });
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ expect(
+ await screen.findByText('An unexpected error occurred.'),
+ ).toBeInTheDocument();
+ });
+
+ it('hides buttons to ungranted users', async () => {
+ fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
+ count: 0,
+ results: [],
+ });
+
+ render(, {
+ wrapper: AppWrapper,
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Manage accesses')).not.toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Create a mailbox')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalCreateMailbox.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx
similarity index 98%
rename from src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalCreateMailbox.test.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx
index b7a08008a..51659aad4 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalCreateMailbox.test.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx
@@ -4,7 +4,7 @@ import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
-import { MailDomain } from '../../types';
+import { MailDomain } from '../../../domains/types';
import { ModalCreateMailbox } from '../ModalCreateMailbox';
const mockMailDomain: MailDomain = {
@@ -64,9 +64,8 @@ describe('ModalCreateMailbox', () => {
fetchMock.restore();
});
- it('renders the modal with all fields and buttons', () => {
+ it('renders all the elements', () => {
renderModalCreateMailbox();
-
const {
formTag,
inputFirstName,
diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/index.ts
similarity index 50%
rename from src/frontend/apps/desk/src/features/mail-domains/components/index.tsx
rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/index.ts
index 6dab85f02..5b2fbc413 100644
--- a/src/frontend/apps/desk/src/features/mail-domains/components/index.tsx
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/index.ts
@@ -1,2 +1,2 @@
+export * from './ModalCreateMailbox';
export * from './MailDomainsContent';
-export * from './MailDomainsLayout';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/index.tsx
new file mode 100644
index 000000000..314dad0cd
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/index.tsx
@@ -0,0 +1,3 @@
+export * from './api';
+export * from './components';
+export * from './types';
diff --git a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/types.ts b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/types.ts
new file mode 100644
index 000000000..da4d1f595
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/types.ts
@@ -0,0 +1,9 @@
+import { UUID } from 'crypto';
+
+export interface MailDomainMailbox {
+ id: UUID;
+ local_part: string;
+ first_name: string;
+ last_name: string;
+ secondary_email: string;
+}
diff --git a/src/frontend/apps/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json
index 4a7173f31..a4a35e89f 100644
--- a/src/frontend/apps/desk/src/i18n/translations.json
+++ b/src/frontend/apps/desk/src/i18n/translations.json
@@ -1,4 +1,5 @@
{
+ "de": { "translation": {} },
"en": {
"translation": {
"{{count}} member_many": "{{count}} members",
@@ -9,6 +10,8 @@
"fr": {
"translation": {
"0 group to display.": "0 groupe à afficher.",
+ "Access icon": "Icône d'accès",
+ "Accesses list card": "Carte de la liste des accès",
"Accessibility statement": "Déclaration d'accessibilité",
"Accessibility: non-compliant": "Accessibilité : non conforme",
"Add a mail domain": "Ajouter un nom de domaine",
@@ -19,8 +22,10 @@
"Add to group": "Ajouter au groupe",
"Address: National Agency for Territorial Cohesion - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07 Paris": "Adresse : Agence Nationale de la Cohésion des Territoires - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07",
"Administration": "Administration",
+ "Administrator": "Administrateur",
"All fields are mandatory.": "Tous les champs sont obligatoires.",
"Are you sure you want to delete {{teamName}} team?": "Êtes-vous sûr de vouloir supprimer le groupe {{teamName}}?",
+ "Are you sure you want to remove this access from the {{domain}} domain?": "Voulez-vous vraiment retirer cet accès du domaine {{domain}} ?",
"Are you sure you want to remove this member from the {{team}} group?": "Voulez-vous vraiment retirer ce membre du groupe {{team}} ?",
"Back to home page": "Retour à l'accueil",
"Cancel": "Annuler",
@@ -90,6 +95,11 @@
"Mailbox created!": "Boîte mail créée !",
"Mailbox creation form": "Formulaire de création de boite mail",
"Mailboxes list": "Liste des boîtes mail",
+ "Mailboxes list card": "Carte liste des boîtes mails",
+ "Manage accesses": "Gérer les accès",
+ "Manage mailboxes": "Gérer les boîtes mails",
+ "Manage {{name}} domain mailboxes": "Gérer les boîtes mails du domaine {{name}}",
+ "Manage {{name}} domain members": "Gérer les membres du domaine {{name}}",
"Marianne Logo": "Logo Marianne",
"Member": "Membre",
"Member icon": "Icône de membre",
@@ -101,9 +111,12 @@
"No domains exist.": "Aucun domaine existant.",
"No mail box was created with this mail domain.": "Aucune boîte mail n'a été créée avec ce nom de domaine.",
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
+ "Open the access options modal": "Ouvrir la fenêtre modale des options d'accès",
"Open the mail domains panel": "Ouvrir le panneau des domaines de messagerie",
"Open the member options modal": "Ouvrir les options de membre dans la fenêtre modale",
+ "Open the modal to delete this access": "Ouvrir la fenêtre modale pour supprimer cet accès",
"Open the modal to delete this member": "Ouvrir la fenêtre modale pour supprimer ce membre",
+ "Open the modal to update the role of this access": "Ouvrir la fenêtre modale pour mettre à jour le rôle de cet accès",
"Open the modal to update the role of this member": "Ouvrir la fenêtre modale pour mettre à jour le rôle de ce membre",
"Open the team options": "Ouvrir les options de groupe",
"Open the teams panel": "Ouvrir le panneau des groupes",
@@ -117,8 +130,11 @@
"Publisher": "Éditeur",
"Radio buttons to update the roles": "Boutons radio pour mettre à jour les rôles",
"Remedy": "Voie de recours",
+ "Remove from domain": "Retirer du domaine",
"Remove from group": "Retirer du groupe",
+ "Remove from the domain": "Retirer du domaine",
"Remove from the group": "Retirer du groupe",
+ "Remove this access from the domain": "Retirer cet accès du domaine",
"Remove this member from the group": "Retirer le membre du groupe",
"Roles": "Rôles",
"Régie": "Régie",
@@ -137,6 +153,7 @@
"Team name": "Nom du groupe",
"Teams": "Équipes",
"The National Agency for Territorial Cohesion undertakes to make its\n service accessible, in accordance with article 47 of law no. 2005-102\n of February 11, 2005.": "L'Agence Nationale de la Cohésion des Territoires s’engage à rendre son service accessible, conformément à l’article 47 de la loi n° 2005-102 du 11 février 2005.",
+ "The access has been removed from the domain": "L'accès a été supprimé du domaine",
"The domain name encounters an error. Please contact our support team to solve the problem:": "Le nom de domaine rencontre une erreur. Veuillez contacter notre support pour résoudre le problème :",
"The member has been removed from the team": "Le membre a été supprimé de votre groupe",
"The role has been updated": "Le rôle a bien été mis à jour",
@@ -160,15 +177,18 @@
"Update the team": "Mettre à jour le groupe",
"Validate": "Valider",
"Validate the modification": "Valider la modification",
+ "Viewer": "Lecteur",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure d’audience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
+ "You are the last owner, you cannot be removed from your domain.": "Vous êtes le dernier propriétaire, vous ne pouvez pas être retiré de votre domaine.",
"You are the last owner, you cannot be removed from your team.": "Vous êtes le dernier propriétaire, vous ne pouvez pas être retiré de votre groupe.",
+ "You are the sole owner of this domain. Make another member the domain owner, before you can change your own role.": "Vous êtes le seul propriétaire de ce domaine. Faites d'un autre membre le propriétaire du domaine avant de modifier votre rôle.",
"You are the sole owner of this group. Make another member the group owner, before you can change your own role.": "Vous êtes l’unique propriétaire de ce groupe. Désignez un autre membre comme propriétaire du groupe, avant de pouvoir modifier votre propre rôle.",
"You can oppose the tracking of your browsing on this website.": "Vous pouvez vous opposer au suivi de votre navigation sur ce site.",
"You can:": "Vous pouvez :",
"You cannot remove other owner.": "Vous ne pouvez pas supprimer un autre propriétaire.",
"You cannot update the role of other owner.": "Vous ne pouvez pas mettre à jour les rôles d'autre propriétaire.",
"You must have minimum 1 character": "Vous devez entrer au moins 1 caractère",
- "Your domain name is being validated. You will not be able to create mailboxes until your domain name has been validated by our team.": "Votre nom de domaine est en cours de validation. Vous ne pourrez créer de boîtes mail que lorsque votre nom de domaine sera validé par notre équipe.",
+ "Your domain name is being validated. You will not be able to create mailboxes until your domain name has been validated by our team.": "Votre nom de domaine est en cours de validation. Vous ne pourrez créer de boîtes mail que lorsque votre nom de domaine sera validé par notre équipe.",
"Your request cannot be processed because the server is experiencing an error. If the problem persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr": "Votre demande ne peut pas être traitée car le serveur rencontre une erreur. Si le problème persiste, veuillez contacter notre support pour résoudre le problème : suiteterritoriale@anct.gouv.fr",
"Your request to create a mailbox cannot be completed due to incorrect settings on our server. Please contact our support team to resolve the problem: suiteterritoriale@anct.gouv.fr": "Votre demande de création de boîte mail ne peut pas être complétée en raison de paramètres incorrects sur notre serveur. Veuillez contacter notre équipe support pour résoudre le problème : suiteterritoriale@anct.gouv.fr",
"[disabled]": "[désactivé]",
diff --git a/src/frontend/apps/desk/src/pages/mail-domains/[slug]/accesses.tsx b/src/frontend/apps/desk/src/pages/mail-domains/[slug]/accesses.tsx
new file mode 100644
index 000000000..910235f49
--- /dev/null
+++ b/src/frontend/apps/desk/src/pages/mail-domains/[slug]/accesses.tsx
@@ -0,0 +1,70 @@
+import { Loader } from '@openfun/cunningham-react';
+import { useRouter as useNavigate } from 'next/navigation';
+import { useRouter } from 'next/router';
+import { ReactElement } from 'react';
+
+import { Box } from '@/components';
+import { TextErrors } from '@/components/TextErrors';
+import { AccessesContent } from '@/features/mail-domains/access-management';
+import {
+ MailDomainsLayout,
+ Role,
+ useMailDomain,
+} from '@/features/mail-domains/domains';
+import { NextPageWithLayout } from '@/types/next';
+
+const MailDomainAccessesPage: NextPageWithLayout = () => {
+ const router = useRouter();
+
+ if (router?.query?.slug && typeof router.query.slug !== 'string') {
+ throw new Error('Invalid mail domain slug');
+ }
+
+ const { slug } = router.query;
+
+ const navigate = useNavigate();
+
+ const {
+ data: mailDomain,
+ error,
+ isError,
+ isLoading,
+ } = useMailDomain({ slug: String(slug) });
+
+ if (error?.status === 404) {
+ navigate.replace(`/404`);
+ return null;
+ }
+
+ if (isError && error) {
+ return ;
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (mailDomain) {
+ const currentRole = mailDomain.abilities.delete
+ ? Role.OWNER
+ : mailDomain.abilities.manage_accesses
+ ? Role.ADMIN
+ : Role.VIEWER;
+
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+MailDomainAccessesPage.getLayout = function getLayout(page: ReactElement) {
+ return {page};
+};
+
+export default MailDomainAccessesPage;
diff --git a/src/frontend/apps/desk/src/pages/mail-domains/[slug].tsx b/src/frontend/apps/desk/src/pages/mail-domains/[slug]/index.tsx
similarity index 76%
rename from src/frontend/apps/desk/src/pages/mail-domains/[slug].tsx
rename to src/frontend/apps/desk/src/pages/mail-domains/[slug]/index.tsx
index cdd2fb3f9..b671410cb 100644
--- a/src/frontend/apps/desk/src/pages/mail-domains/[slug].tsx
+++ b/src/frontend/apps/desk/src/pages/mail-domains/[slug]/index.tsx
@@ -5,11 +5,14 @@ import { ReactElement } from 'react';
import { Box } from '@/components';
import { TextErrors } from '@/components/TextErrors';
-import { MailDomainsContent, MailDomainsLayout } from '@/features/mail-domains';
-import { useMailDomain } from '@/features/mail-domains/api/useMailDomain';
+import {
+ MailDomainsLayout,
+ useMailDomain,
+} from '@/features/mail-domains/domains';
+import { MailDomainsContent } from '@/features/mail-domains/mailboxes';
import { NextPageWithLayout } from '@/types/next';
-const Page: NextPageWithLayout = () => {
+const MailboxesPage: NextPageWithLayout = () => {
const router = useRouter();
if (router?.query?.slug && typeof router.query.slug !== 'string') {
@@ -22,9 +25,9 @@ const Page: NextPageWithLayout = () => {
const {
data: mailDomain,
- error: error,
+ error,
isError,
- isLoading: isLoading,
+ isLoading,
} = useMailDomain({ slug: String(slug) });
if (error?.status === 404) {
@@ -47,8 +50,8 @@ const Page: NextPageWithLayout = () => {
}
};
-Page.getLayout = function getLayout(page: ReactElement) {
+MailboxesPage.getLayout = function getLayout(page: ReactElement) {
return {page};
};
-export default Page;
+export default MailboxesPage;
diff --git a/src/frontend/apps/desk/src/pages/mail-domains/add.tsx b/src/frontend/apps/desk/src/pages/mail-domains/add.tsx
index 1e4227b95..353eaf5b5 100644
--- a/src/frontend/apps/desk/src/pages/mail-domains/add.tsx
+++ b/src/frontend/apps/desk/src/pages/mail-domains/add.tsx
@@ -1,8 +1,10 @@
import React, { ReactElement } from 'react';
import { Box } from '@/components';
-import { MailDomainsLayout } from '@/features/mail-domains';
-import { ModalAddMailDomain } from '@/features/mail-domains/components/ModalAddMailDomain';
+import {
+ MailDomainsLayout,
+ ModalAddMailDomain,
+} from '@/features/mail-domains/domains';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
diff --git a/src/frontend/apps/desk/src/pages/mail-domains/index.tsx b/src/frontend/apps/desk/src/pages/mail-domains/index.tsx
index 15c53c4c0..2b5c4dc04 100644
--- a/src/frontend/apps/desk/src/pages/mail-domains/index.tsx
+++ b/src/frontend/apps/desk/src/pages/mail-domains/index.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box } from '@/components';
-import { MailDomainsLayout } from '@/features/mail-domains';
+import { MailDomainsLayout } from '@/features/mail-domains/domains';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
diff --git a/src/frontend/apps/e2e/__tests__/app-desk/mail-domain-create-mailbox.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/mail-domain-create-mailbox.spec.ts
index 53c1cfa2c..5b0287531 100644
--- a/src/frontend/apps/e2e/__tests__/app-desk/mail-domain-create-mailbox.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-desk/mail-domain-create-mailbox.spec.ts
@@ -1,8 +1,6 @@
import { Page, expect, test } from '@playwright/test';
-import {
- CreateMailboxParams,
- MailDomain,
-} from 'app-desk/src/features/mail-domains';
+import { MailDomain } from 'app-desk/src/features/mail-domains/domains';
+import { CreateMailboxParams } from 'app-desk/src/features/mail-domains/mailboxes';
import { keyCloakSignIn } from './common';
@@ -181,9 +179,10 @@ test.describe('Mail domain create mailbox', () => {
request.url().includes('/mail-domains/domainfr/mailboxes/') &&
request.method() === 'POST'
) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const payload: Omit =
- request.postDataJSON();
+ const payload = request.postDataJSON() as Omit<
+ CreateMailboxParams,
+ 'mailDomainId'
+ >;
if (payload) {
isCreateMailboxRequestSentWithExpectedPayload =
diff --git a/src/frontend/apps/e2e/__tests__/app-desk/mail-domain.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/mail-domain.spec.ts
index c273fd55e..fbbb2aaef 100644
--- a/src/frontend/apps/e2e/__tests__/app-desk/mail-domain.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-desk/mail-domain.spec.ts
@@ -1,5 +1,5 @@
import { Page, expect, test } from '@playwright/test';
-import { MailDomain } from 'app-desk/src/features/mail-domains';
+import { MailDomain } from 'app-desk/src/features/mail-domains/domains';
import { keyCloakSignIn } from './common';
diff --git a/src/frontend/apps/e2e/__tests__/app-desk/mail-domains.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/mail-domains.spec.ts
index 6b575297e..f40f8dcf5 100644
--- a/src/frontend/apps/e2e/__tests__/app-desk/mail-domains.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-desk/mail-domains.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { MailDomain } from 'app-desk/src/features/mail-domains';
+import { MailDomain } from 'app-desk/src/features/mail-domains/domains';
import { keyCloakSignIn } from './common';