(frontend) add mail domain access management

- access management view is ready to use
get, patch and delete requests once backend
is ready. How to create accesses with post
will come later in a future commit.
- update translations and component tests.
- reduce gap between mail domains feature
logo and mail domain name in top banner
This commit is contained in:
daproclaima
2024-09-23 16:06:51 +02:00
committed by Anthony LC
parent 2894b9a999
commit 315a6ab931
30 changed files with 2593 additions and 1 deletions

View File

@@ -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

View File

@@ -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(() => <div>AccessContent</div>),
}),
);
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(<MailDomainAccessesPage />, { 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');
});
});

View File

@@ -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());
});
});

View File

@@ -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);
});
});

View File

@@ -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());
});
});

View File

@@ -0,0 +1,3 @@
export * from './useMailDomainAccesses';
export * from './useUpdateMailDomainAccess';
export * from './useDeleteMailDomainAccess';

View File

@@ -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<void> => {
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<void, APIError, DeleteMailDomainAccessProps>({
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);
}
},
});
};

View File

@@ -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<Access>;
export const getMailDomainAccesses = async ({
page,
slug,
ordering,
}: MailDomainAccessesAPIParams): Promise<AccessesResponse> => {
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<AccessesResponse>;
};
export const KEY_LIST_MAIL_DOMAIN_ACCESSES = 'mail-domains-accesses';
export function useMailDomainAccesses(
params: MailDomainAccessesAPIParams,
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
) {
return useQuery<AccessesResponse, APIError, AccessesResponse>({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES, params],
queryFn: () => getMailDomainAccesses(params),
...queryConfig,
});
}

View File

@@ -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<Access> => {
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<Access>;
};
type UseUpdateMailDomainAccess = Partial<Access>;
type UseUpdateMailDomainAccessOptions = UseMutationOptions<
Access,
APIError,
UseUpdateMailDomainAccess
>;
export const useUpdateMailDomainAccess = (
options?: UseUpdateMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
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);
}
},
});
};

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 48 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.85735 7.9065L0 3.05123L3.05331 0L41.593 38.5397L38.5418 41.591L28.4121 31.4612C27.3276 31.7198 26.2034 31.8554 25.0584 31.8554C22.0135 31.8554 19.1145 30.896 16.7161 29.2275V33.941H2.11688V25.5986C2.11688 23.3045 3.99392 21.4274 6.28807 21.4274H13.108C14.5262 21.4274 15.7984 22.1991 16.6118 23.3462C18.6369 26.2055 21.7569 27.5528 24.6246 27.6759L21.953 25.0021C20.5035 24.4223 19.2334 23.4421 18.322 22.1574C17.0706 20.4055 15.2144 19.4044 13.2123 19.3627C13.5397 18.8037 14.0819 18.2886 14.7765 17.8256L13.5522 16.6013C12.4281 18.2573 10.5302 19.3418 8.37367 19.3418C4.91158 19.3418 2.11688 16.5471 2.11688 13.085C2.11688 10.9285 3.20139 9.03063 4.85735 7.9065ZM24.1804 15.1915C24.4786 15.1769 24.7727 15.1706 25.0584 15.1706C29.3965 15.1706 35.403 16.7974 36.9046 19.3418C34.9025 19.3835 33.0463 20.3846 31.7949 22.1365C31.7031 22.2679 31.6072 22.3951 31.5071 22.5203L24.1804 15.1915ZM32.9962 24.0094C33.3174 23.634 33.4863 23.3754 33.5051 23.3462C34.1933 22.3659 35.403 21.4274 37.0089 21.4274H43.8288C46.123 21.4274 48 23.3045 48 25.5986V33.941H42.9299L32.9962 24.0094ZM41.7432 19.3418C38.2811 19.3418 35.4864 16.5471 35.4864 13.085C35.4864 9.62294 38.2811 6.82824 41.7432 6.82824C45.2053 6.82824 48 9.62294 48 13.085C48 16.5471 45.2053 19.3418 41.7432 19.3418ZM25.0584 13.085C21.5964 13.085 18.8017 10.2903 18.8017 6.82824C18.8017 3.36615 21.5964 0.571453 25.0584 0.571453C28.5205 0.571453 31.3152 3.36615 31.3152 6.82824C31.3152 10.2903 28.5205 13.085 25.0584 13.085Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -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 (
<>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the access options modal')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
{(mailDomain.abilities.put || mailDomain.abilities.patch) && (
<Button
aria-label={t('Open the modal to update the role of this access')}
onClick={() => {
setIsModalRoleOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
edit
</span>
}
>
<Text $theme="primary">{t('Update role')}</Text>
</Button>
)}
{mailDomain.abilities.delete && (
<Button
aria-label={t('Open the modal to delete this access')}
onClick={() => {
setIsModalDeleteOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
delete
</span>
}
>
<Text $theme="primary">{t('Remove from domain')}</Text>
</Button>
)}
</Box>
</DropButton>
{isModalRoleOpen &&
(mailDomain.abilities.put || mailDomain.abilities.patch) && (
<ModalRole
access={access}
currentRole={currentRole}
onClose={() => setIsModalRoleOpen(false)}
slug={mailDomain.slug}
/>
)}
{isModalDeleteOpen && mailDomain.abilities.delete && (
<ModalDelete
access={access}
currentRole={currentRole}
onClose={() => setIsModalDeleteOpen(false)}
mailDomain={mailDomain}
/>
)}
</>
);
};

View File

@@ -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;
}) => (
<>
<TopBanner mailDomain={mailDomain} />
<AccessesGrid mailDomain={mailDomain} currentRole={currentRole} />
</>
);
const TopBanner = ({ mailDomain }: { mailDomain: MailDomain }) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Box
$direction="column"
$margin={{ all: 'big', bottom: 'tiny' }}
$gap="1rem"
>
<Box
$direction="row"
$align="center"
$gap="2.25rem"
$justify="space-between"
>
<Box $direction="row" $margin="none" $gap="0.5rem">
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{mailDomain?.name}
</Text>
</Box>
</Box>
<Box $direction="row" $justify="flex-end">
<Box $display="flex" $direction="row" $gap="8rem">
{mailDomain?.abilities?.manage_accesses && (
<Button
color="tertiary"
aria-label={t('Manage {{name}} domain mailboxes', {
name: mailDomain?.name,
})}
onClick={() => router.push(`/mail-domains/${mailDomain.slug}/`)}
>
{t('Manage mailboxes')}
</Button>
)}
</Box>
</Box>
</Box>
);
};

View File

@@ -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<string, string> = {
'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<string, string>} 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<SortModel>([]);
const [accesses, setAccesses] = useState<Access[]>([]);
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 (
<Card
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
$css={`
& .c__pagination__goto {
display: none;
}
& table th:first-child,
& table td:first-child {
padding-right: 0;
width: 3.5rem;
}
& table td:last-child {
text-align: right;
}
`}
aria-label={t('Accesses list card')}
>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
id: 'icon-user',
renderCell() {
return (
<Box $direction="row" $align="center">
<IconUser
aria-label={t('Access icon')}
width={20}
height={20}
color={colorsTokens()['primary-600']}
/>
</Box>
);
},
},
{
headerName: t('Names'),
field: 'user.name',
},
{
field: 'user.email',
headerName: t('Emails'),
},
{
field: 'localizedRole',
headerName: t('Roles'),
},
{
id: 'column-actions',
renderCell: ({ row }) => (
<AccessAction
mailDomain={mailDomain}
access={row}
currentRole={currentRole}
/>
),
},
]}
rows={accesses}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
/>
</Card>
);
};

View File

@@ -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 (
<RadioGroup>
{rolesToDisplay?.map((role) => {
switch (role) {
case Role.VIEWER:
return (
<Radio
key={Role.VIEWER}
label={t('Viewer')}
value={Role.VIEWER}
name="role"
onChange={(evt) => setRole(evt.target.value as Role)}
defaultChecked={currentRole === Role.VIEWER}
disabled={disabled}
/>
);
case Role.ADMIN:
return (
<Radio
key={Role.ADMIN}
label={t('Administrator')}
value={Role.ADMIN}
name="role"
onChange={(evt) => setRole(evt.target.value as Role)}
defaultChecked={currentRole === Role.ADMIN}
disabled={disabled}
/>
);
case Role.OWNER:
return (
<Radio
key={Role.OWNER}
label={t('Owner')}
value={Role.OWNER}
name="role"
onChange={(evt) => setRole(evt.target.value as Role)}
defaultChecked={currentRole === Role.OWNER}
disabled={disabled || currentRole !== Role.OWNER}
/>
);
}
})}
</RadioGroup>
);
};

View File

@@ -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 (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button color="secondary" fullWidth onClick={() => onClose()}>
{t('Cancel')}
</Button>
}
onClose={onClose}
rightActions={
<Button
color="primary"
fullWidth
onClick={() => {
removeMailDomainAccess({
slug: mailDomain.slug,
accessId: access.id,
});
}}
disabled={isNotAllowed}
>
{t('Remove from the domain')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<Text $size="h3" $margin="none">
{t('Remove this access from the domain')}
</Text>
</Box>
}
>
<Box aria-label={t('Radio buttons to update the roles')}>
<Text>
{t(
'Are you sure you want to remove this access from the {{domain}} domain?',
{ domain: mailDomain.name },
)}
</Text>
{isErrorUpdate && (
<TextErrors
$margin={{ bottom: 'small' }}
causes={errorDeletion.cause}
/>
)}
{(isLastOwner || isOtherOwner) && (
<Text
$theme="warning"
$direction="row"
$align="center"
$gap="0.5rem"
$margin="tiny"
$justify="center"
>
<span className="material-icons">warning</span>
{isLastOwner &&
t(
'You are the last owner, you cannot be removed from your domain.',
)}
{isOtherOwner && t('You cannot remove other owner.')}
</Text>
)}
<Text
as="p"
$padding="big"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
>
<IconUser width={20} height={20} aria-hidden="true" />
<Text>{access.user.name}</Text>
</Text>
</Box>
</Modal>
);
};

View File

@@ -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 (
<Modal
isOpen
leftActions={
<Button
color="secondary"
fullWidth
onClick={() => onClose()}
disabled={isPending}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
onClick={() => {
updateMailDomainAccess({
role: localRole,
slug,
accessId: access.id,
});
}}
disabled={isNotAllowed || isPending}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={t('Update the role')}
>
<Box aria-label={t('Radio buttons to update the roles')}>
{isErrorUpdate && (
<TextErrors
$margin={{ bottom: 'small' }}
causes={errorUpdate.cause}
/>
)}
{(isLastOwner || isOtherOwner) && (
<Text
$theme="warning"
$direction="row"
$align="center"
$gap="0.5rem"
$margin={{ bottom: 'tiny' }}
$justify="center"
>
<span className="material-icons">warning</span>
{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.')}
</Text>
)}
<ChooseRole
availableRoles={access.can_set_role_to}
currentRole={currentRole}
disabled={isNotAllowed}
setRole={setLocalRole}
/>
</Box>
</Modal>
);
};

View File

@@ -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(() => <div>Mock ModalRole</div>),
}));
jest.mock('../ModalDelete', () => ({
ModalDelete: jest.fn(() => <div>Mock ModalDelete</div>),
}));
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(
<AccessAction
access={access}
currentRole={currentRole}
mailDomain={mailDomain}
/>,
{ 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();
});
});

View File

@@ -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(() => <div>Mock AccessesGrid</div>),
}),
);
jest.mock('@/features/mail-domains/assets/mail-domains-logo.svg', () => () => (
<svg data-testid="mail-domains-logo" />
));
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(
<AccessesContent currentRole={currentRole} mailDomain={mailDomain} />,
{
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/`);
});
});
});

View File

@@ -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(() => <div>Mock AccessAction</div>),
}),
);
jest.mock('@/assets/icons/icon-user.svg', () => () => (
<svg data-testid="icon-user" />
));
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(<AccessesGrid mailDomain={mockMailDomain} currentRole={role} />, {
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);
});
});

View File

@@ -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<React.ComponentProps<typeof ChooseRole>> = {},
) => {
const defaultProps = {
availableRoles: [Role.VIEWER, Role.ADMIN],
currentRole: Role.ADMIN,
disabled: false,
setRole: mockSetRole,
...props,
};
return render(<ChooseRole {...defaultProps} />, { 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');
});
});

View File

@@ -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<ModalDeleteProps> = {}) =>
render(
<ModalDelete
access={mockAccess}
currentRole={Role.ADMIN}
onClose={mockClose}
mailDomain={mockMailDomain}
{...props}
/>,
{
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();
});
});

View File

@@ -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<React.ComponentProps<typeof ModalRole>>,
) => {
(useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast });
(useWhoAmI as jest.Mock).mockReturnValue({
isLastOwner,
isOtherOwner,
});
return render(
<ModalRole
access={access}
currentRole={props?.currentRole ?? Role.ADMIN}
onClose={mockOnClose}
slug="domain-slug"
/>,
{ 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();
});
});

View File

@@ -0,0 +1,5 @@
export * from './AccessesContent';
export * from './AccessesGrid';
export * from './ChooseRole';
export * from './ModalRole';
export * from './ModalDelete';

View File

@@ -0,0 +1 @@
export const PAGE_SIZE = 20;

View File

@@ -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();
});
});

View File

@@ -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,
};
};

View File

@@ -0,0 +1,3 @@
export * from './api';
export * from './components';
export * from './types';

View File

@@ -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[];
}

View File

@@ -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 sengage à rendre son service accessible, conformément à larticle 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 daudience, 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 lunique 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é]",

View File

@@ -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 <TextErrors causes={error?.cause} />;
}
if (isLoading) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
if (mailDomain) {
const currentRole = mailDomain.abilities.delete
? Role.OWNER
: mailDomain.abilities.manage_accesses
? Role.ADMIN
: Role.VIEWER;
return (
<AccessesContent mailDomain={mailDomain} currentRole={currentRole} />
);
}
return null;
};
MailDomainAccessesPage.getLayout = function getLayout(page: ReactElement) {
return <MailDomainsLayout>{page}</MailDomainsLayout>;
};
export default MailDomainAccessesPage;