✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useMailDomainAccesses';
|
||||
export * from './useUpdateMailDomainAccess';
|
||||
export * from './useDeleteMailDomainAccess';
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 |
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './AccessesContent';
|
||||
export * from './AccessesGrid';
|
||||
export * from './ChooseRole';
|
||||
export * from './ModalRole';
|
||||
export * from './ModalDelete';
|
||||
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 20;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,8 +177,11 @@
|
||||
"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 :",
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user