diff --git a/CHANGELOG.md b/CHANGELOG.md index 61fe9eb91..c30ecfda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to - ✨(backend) domain accesses create API #428 - 🥅(frontend) catch new errors on mailbox creation #392 - ✨(api) domain accesses delete API #433 +- ✨(frontend) add mail domain access management #413 ### Fixed diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/__tests__/accesses.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/__tests__/accesses.test.tsx new file mode 100644 index 000000000..f74b449b1 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/__tests__/accesses.test.tsx @@ -0,0 +1,143 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { useRouter as useNavigate } from 'next/navigation'; +import { useRouter } from 'next/router'; + +import { AccessesContent } from '@/features/mail-domains/access-management'; +import { Role } from '@/features/mail-domains/domains'; +import MailDomainAccessesPage from '@/pages/mail-domains/[slug]/accesses'; +import { AppWrapper } from '@/tests/utils'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock( + '@/features/mail-domains/access-management/components/AccessesContent', + () => ({ + AccessesContent: jest.fn(() =>
AccessContent
), + }), +); + +describe('MailDomainAccessesPage', () => { + const mockRouterReplace = jest.fn(); + const mockNavigate = { replace: mockRouterReplace }; + const mockRouter = { + query: { slug: 'example-slug' }, + }; + + (useRouter as jest.Mock).mockReturnValue(mockRouter); + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.reset(); + (useRouter as jest.Mock).mockReturnValue(mockRouter); + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + }); + + afterEach(() => { + fetchMock.restore(); + }); + + const renderPage = () => { + render(, { wrapper: AppWrapper }); + }; + + it('renders loader while loading', () => { + // Simulate a never-resolving promise to mock loading + fetchMock.mock( + `end:/mail-domains/${mockRouter.query.slug}/`, + new Promise(() => {}), + ); + + renderPage(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('renders error message when there is an error', async () => { + fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, { + status: 500, + }); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByText('Something bad happens, please retry.'), + ).toBeInTheDocument(); + }); + }); + + it('redirects to 404 page if the domain is not found', async () => { + fetchMock.mock( + `end:/mail-domains/${mockRouter.query.slug}/`, + { + body: { detail: 'Not found' }, + status: 404, + }, + { overwriteRoutes: true }, + ); + + renderPage(); + + await waitFor(() => { + expect(mockRouterReplace).toHaveBeenCalledWith('/404'); + }); + }); + + it('renders the AccessesContent when data is available', async () => { + const mockMailDomain = { + id: '1-1-1-1-1', + name: 'example.com', + slug: 'example-com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + abilities: { + get: true, + patch: true, + put: true, + post: true, + delete: true, + manage_accesses: true, + }, + }; + + fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, { + body: mockMailDomain, + status: 200, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('AccessContent')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(AccessesContent).toHaveBeenCalledWith( + { + mailDomain: mockMailDomain, + currentRole: Role.OWNER, + }, + {}, // adding this empty object is necessary to load jest context + ); + }); + }); + + it('throws an error when slug is invalid', () => { + console.error = jest.fn(); // Suppress expected error in jest logs + + (useRouter as jest.Mock).mockReturnValue({ + query: { slug: ['invalid-array-slug-in-array'] }, + }); + + expect(() => renderPage()).toThrow('Invalid mail domain slug'); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useDeleteMailDomainAccess.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useDeleteMailDomainAccess.test.tsx new file mode 100644 index 000000000..01a1f0fe8 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useDeleteMailDomainAccess.test.tsx @@ -0,0 +1,109 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; + +import { APIError } from '@/api'; +import { AppWrapper } from '@/tests/utils'; + +import { + deleteMailDomainAccess, + useDeleteMailDomainAccess, +} from '../useDeleteMailDomainAccess'; + +describe('deleteMailDomainAccess', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('deletes the access successfully', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 204, // No content status + }); + + await deleteMailDomainAccess({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + }); + + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-slug/accesses/1-1-1-1-1/', + ); + }); + + it('throws an error when the API call fails', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + await expect( + deleteMailDomainAccess({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + }), + ).rejects.toThrow(APIError); + expect(fetchMock.calls()).toHaveLength(1); + }); +}); + +describe('useDeleteMailDomainAccess', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('deletes the access and calls onSuccess callback', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 204, // No content status + }); + + const onSuccess = jest.fn(); + + const { result } = renderHook( + () => useDeleteMailDomainAccess({ onSuccess }), + { + wrapper: AppWrapper, + }, + ); + + result.current.mutate({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + }); + + await waitFor(() => expect(fetchMock.calls()).toHaveLength(1)); + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith( + undefined, + { slug: 'example-slug', accessId: '1-1-1-1-1' }, + undefined, + ), + ); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-slug/accesses/1-1-1-1-1/', + ); + }); + + it('calls onError when the API fails', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + const onError = jest.fn(); + + const { result } = renderHook( + () => useDeleteMailDomainAccess({ onError }), + { + wrapper: AppWrapper, + }, + ); + + result.current.mutate({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + }); + + await waitFor(() => expect(fetchMock.calls()).toHaveLength(1)); + await waitFor(() => expect(onError).toHaveBeenCalled()); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useMailDomainAccesses.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useMailDomainAccesses.test.tsx new file mode 100644 index 000000000..909297c61 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useMailDomainAccesses.test.tsx @@ -0,0 +1,133 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; + +import { APIError } from '@/api'; +import { AppWrapper } from '@/tests/utils'; + +import { Role } from '../../../domains'; +import { Access } from '../../types'; +import { + getMailDomainAccesses, + useMailDomainAccesses, +} from '../useMailDomainAccesses'; + +const mockAccess: Access = { + id: '1-1-1-1-1', + role: Role.ADMIN, + user: { + id: '2-1-1-1-1', + name: 'username1', + email: 'user1@test.com', + }, + can_set_role_to: [Role.VIEWER, Role.ADMIN], +}; + +describe('getMailDomainAccesses', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('fetches the list of accesses successfully', async () => { + const mockResponse = { + count: 2, + results: [ + mockAccess, + { + id: '2', + role: Role.VIEWER, + user: { id: '12', name: 'username2', email: 'user2@test.com' }, + can_set_role_to: [Role.VIEWER], + }, + ], + }; + + fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', { + status: 200, + body: mockResponse, + }); + + const result = await getMailDomainAccesses({ + page: 1, + slug: 'example-slug', + }); + + expect(result).toEqual(mockResponse); + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-slug/accesses/?page=1', + ); + }); + + it('throws an error when the API call fails', async () => { + fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + await expect( + getMailDomainAccesses({ page: 1, slug: 'example-slug' }), + ).rejects.toThrow(APIError); + expect(fetchMock.calls()).toHaveLength(1); + }); +}); + +describe('useMailDomainAccesses', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('fetches and returns the accesses data using the hook', async () => { + const mockResponse = { + count: 2, + results: [ + mockAccess, + { + id: '2', + role: Role.VIEWER, + user: { id: '12', name: 'username2', email: 'user2@test.com' }, + can_set_role_to: [Role.VIEWER], + }, + ], + }; + + fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', { + status: 200, + body: mockResponse, + }); + + const { result } = renderHook( + () => useMailDomainAccesses({ page: 1, slug: 'example-slug' }), + { + wrapper: AppWrapper, + }, + ); + + await waitFor(() => result.current.isSuccess); + + await waitFor(() => expect(result.current.data).toEqual(mockResponse)); + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-slug/accesses/?page=1', + ); + }); + + it('handles an API error properly with the hook', async () => { + fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + const { result } = renderHook( + () => useMailDomainAccesses({ page: 1, slug: 'example-slug' }), + { + wrapper: AppWrapper, + }, + ); + + await waitFor(() => result.current.isError); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(APIError)); + expect(result.current.error?.message).toBe('Failed to get the accesses'); + expect(fetchMock.calls()).toHaveLength(1); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useUpdateMailDomainAccess.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useUpdateMailDomainAccess.test.tsx new file mode 100644 index 000000000..89a2aff30 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/__tests__/useUpdateMailDomainAccess.test.tsx @@ -0,0 +1,139 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; + +import { APIError } from '@/api'; +import { AppWrapper } from '@/tests/utils'; + +import { Role } from '../../../domains'; +import { Access } from '../../types'; +import { + updateMailDomainAccess, + useUpdateMailDomainAccess, +} from '../useUpdateMailDomainAccess'; + +const mockAccess: Access = { + id: '1-1-1-1-1', + role: Role.ADMIN, + user: { + id: '2-1-1-1-1', + name: 'username1', + email: 'user1@test.com', + }, + can_set_role_to: [Role.VIEWER, Role.ADMIN], +}; + +describe('updateMailDomainAccess', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('updates the access role successfully', async () => { + const mockResponse = { + ...mockAccess, + role: Role.VIEWER, + }; + + fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 200, + body: mockResponse, + }); + + const result = await updateMailDomainAccess({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + role: Role.VIEWER, + }); + + expect(result).toEqual(mockResponse); + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-slug/accesses/1-1-1-1-1/', + ); + }); + + it('throws an error when the API call fails', async () => { + fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + await expect( + updateMailDomainAccess({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + role: Role.VIEWER, + }), + ).rejects.toThrow(APIError); + expect(fetchMock.calls()).toHaveLength(1); + }); +}); + +describe('useUpdateMailDomainAccess', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('updates the role and calls onSuccess callback', async () => { + const mockResponse = { + ...mockAccess, + role: Role.VIEWER, + }; + + fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 200, + body: mockResponse, + }); + + const onSuccess = jest.fn(); + + const { result } = renderHook( + () => useUpdateMailDomainAccess({ onSuccess }), + { + wrapper: AppWrapper, + }, + ); + + result.current.mutate({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + role: Role.VIEWER, + }); + + await waitFor(() => expect(fetchMock.calls()).toHaveLength(1)); + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith( + mockResponse, // data + { slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables + undefined, // context + ), + ); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-slug/accesses/1-1-1-1-1/', + ); + }); + + it('calls onError when the API fails', async () => { + fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + const onError = jest.fn(); + + const { result } = renderHook( + () => useUpdateMailDomainAccess({ onError }), + { + wrapper: AppWrapper, + }, + ); + + result.current.mutate({ + slug: 'example-slug', + accessId: '1-1-1-1-1', + role: Role.VIEWER, + }); + + await waitFor(() => expect(fetchMock.calls()).toHaveLength(1)); + await waitFor(() => expect(onError).toHaveBeenCalled()); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/index.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/index.ts new file mode 100644 index 000000000..4f7ef78f4 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/index.ts @@ -0,0 +1,3 @@ +export * from './useMailDomainAccesses'; +export * from './useUpdateMailDomainAccess'; +export * from './useDeleteMailDomainAccess'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainAccess.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainAccess.tsx new file mode 100644 index 000000000..46f374320 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainAccess.tsx @@ -0,0 +1,72 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { + KEY_LIST_MAIL_DOMAIN, + KEY_MAIL_DOMAIN, +} from '@/features/mail-domains/domains'; + +import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses'; + +interface DeleteMailDomainAccessProps { + slug: string; + accessId: string; +} + +export const deleteMailDomainAccess = async ({ + slug, + accessId, +}: DeleteMailDomainAccessProps): Promise => { + const response = await fetchAPI( + `mail-domains/${slug}/accesses/${accessId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete the access', + await errorCauses(response), + ); + } +}; + +type UseDeleteMailDomainAccessOptions = UseMutationOptions< + void, + APIError, + DeleteMailDomainAccessProps +>; + +export const useDeleteMailDomainAccess = ( + options?: UseDeleteMailDomainAccessOptions, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteMailDomainAccess, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_MAIL_DOMAIN], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_MAIL_DOMAIN], + }); + if (options?.onSuccess) { + options.onSuccess(data, variables, context); + } + }, + onError: (error, variables, context) => { + if (options?.onError) { + options.onError(error, variables, context); + } + }, + }); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useMailDomainAccesses.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useMailDomainAccesses.tsx new file mode 100644 index 000000000..75ddfeb64 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useMailDomainAccesses.tsx @@ -0,0 +1,49 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; + +import { Access } from '../types'; + +export type MailDomainAccessesAPIParams = { + page: number; + slug: string; + ordering?: string; +}; + +type AccessesResponse = APIList; + +export const getMailDomainAccesses = async ({ + page, + slug, + ordering, +}: MailDomainAccessesAPIParams): Promise => { + let url = `mail-domains/${slug}/accesses/?page=${page}`; + + if (ordering) { + url += '&ordering=' + ordering; + } + + const response = await fetchAPI(url); + + if (!response.ok) { + throw new APIError( + 'Failed to get the accesses', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_MAIL_DOMAIN_ACCESSES = 'mail-domains-accesses'; + +export function useMailDomainAccesses( + params: MailDomainAccessesAPIParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES, params], + queryFn: () => getMailDomainAccesses(params), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUpdateMailDomainAccess.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUpdateMailDomainAccess.ts new file mode 100644 index 000000000..92712e38c --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUpdateMailDomainAccess.ts @@ -0,0 +1,74 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { KEY_MAIL_DOMAIN, Role } from '@/features/mail-domains/domains'; + +import { Access } from '../types'; + +import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses'; + +interface UpdateMailDomainAccessProps { + slug: string; + accessId: string; + role: Role; +} + +export const updateMailDomainAccess = async ({ + slug, + accessId, + role, +}: UpdateMailDomainAccessProps): Promise => { + const response = await fetchAPI( + `mail-domains/${slug}/accesses/${accessId}/`, + { + method: 'PATCH', + body: JSON.stringify({ + role, + }), + }, + ); + + if (!response.ok) { + throw new APIError('Failed to update role', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +type UseUpdateMailDomainAccess = Partial; + +type UseUpdateMailDomainAccessOptions = UseMutationOptions< + Access, + APIError, + UseUpdateMailDomainAccess +>; + +export const useUpdateMailDomainAccess = ( + options?: UseUpdateMailDomainAccessOptions, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateMailDomainAccess, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_MAIL_DOMAIN], + }); + if (options?.onSuccess) { + options.onSuccess(data, variables, context); + } + }, + onError: (error, variables, context) => { + if (options?.onError) { + options.onError(error, variables, context); + } + }, + }); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/assets/icon-remove-member.svg b/src/frontend/apps/desk/src/features/mail-domains/access-management/assets/icon-remove-member.svg new file mode 100644 index 000000000..4316c35c5 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/assets/icon-remove-member.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessAction.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessAction.tsx new file mode 100644 index 000000000..b8f1f053f --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessAction.tsx @@ -0,0 +1,104 @@ +import { Button } from '@openfun/cunningham-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, DropButton, IconOptions, Text } from '@/components'; + +import { MailDomain, Role } from '../../domains/types'; +import { Access } from '../types'; + +import { ModalDelete } from './ModalDelete'; +import { ModalRole } from './ModalRole'; + +interface AccessActionProps { + access: Access; + currentRole: Role; + mailDomain: MailDomain; +} + +export const AccessAction = ({ + access, + currentRole, + mailDomain, +}: AccessActionProps) => { + const { t } = useTranslation(); + const [isModalRoleOpen, setIsModalRoleOpen] = useState(false); + const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false); + const [isDropOpen, setIsDropOpen] = useState(false); + + if ( + currentRole === Role.VIEWER || + (access.role === Role.OWNER && currentRole === Role.ADMIN) + ) { + return null; + } + + return ( + <> + + } + onOpenChange={(isOpen) => setIsDropOpen(isOpen)} + isOpen={isDropOpen} + > + + {(mailDomain.abilities.put || mailDomain.abilities.patch) && ( + + )} + {mailDomain.abilities.delete && ( + + )} + + + {isModalRoleOpen && + (mailDomain.abilities.put || mailDomain.abilities.patch) && ( + setIsModalRoleOpen(false)} + slug={mailDomain.slug} + /> + )} + {isModalDeleteOpen && mailDomain.abilities.delete && ( + setIsModalDeleteOpen(false)} + mailDomain={mailDomain} + /> + )} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesContent.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesContent.tsx new file mode 100644 index 000000000..3d7a59a5c --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesContent.tsx @@ -0,0 +1,66 @@ +import { Button } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { AccessesGrid } from '@/features/mail-domains/access-management/components/AccessesGrid'; +import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.svg'; + +import { MailDomain, Role } from '../../domains'; + +export const AccessesContent = ({ + mailDomain, + currentRole, +}: { + mailDomain: MailDomain; + currentRole: Role; +}) => ( + <> + + + +); + +const TopBanner = ({ mailDomain }: { mailDomain: MailDomain }) => { + const router = useRouter(); + const { t } = useTranslation(); + + return ( + + + + + + + + + {mailDomain?.abilities?.manage_accesses && ( + + )} + + + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesGrid.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesGrid.tsx new file mode 100644 index 000000000..d2b2014b7 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesGrid.tsx @@ -0,0 +1,177 @@ +import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import IconUser from '@/assets/icons/icon-user.svg'; +import { Box, Card, TextErrors } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { MailDomain, Role } from '../../domains'; +import { useMailDomainAccesses } from '../api'; +import { PAGE_SIZE } from '../conf'; +import { Access } from '../types'; + +import { AccessAction } from './AccessAction'; + +interface AccessesGridProps { + mailDomain: MailDomain; + currentRole: Role; +} + +type SortModelItem = { + field: string; + sort: 'asc' | 'desc' | null; +}; + +const defaultOrderingMapping: Record = { + 'user.name': 'user__name', + 'user.email': 'user__email', + localizedRole: 'role', +}; + +/** + * Formats the sorting model based on a given mapping. + * @param {SortModelItem} sortModel The sorting model item containing field and sort direction. + * @param {Record} mapping The mapping object to map field names. + * @returns {string} The formatted sorting string. + * @todo same as team members grid + */ +function formatSortModel( + sortModel: SortModelItem, + mapping = defaultOrderingMapping, +) { + const { field, sort } = sortModel; + const orderingField = mapping[field] || field; + return sort === 'desc' ? `-${orderingField}` : orderingField; +} + +/** + * @param mailDomain + * @param currentRole + * @todo same as team members grid + */ +export const AccessesGrid = ({ + mailDomain, + currentRole, +}: AccessesGridProps) => { + const { t } = useTranslation(); + const { colorsTokens } = useCunninghamTheme(); + const pagination = usePagination({ + pageSize: PAGE_SIZE, + }); + const [sortModel, setSortModel] = useState([]); + const [accesses, setAccesses] = useState([]); + const { page, pageSize, setPagesCount } = pagination; + + const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined; + + const { data, isLoading, error } = useMailDomainAccesses({ + slug: mailDomain.slug, + page, + ordering, + }); + + useEffect(() => { + if (isLoading) { + return; + } + + const localizedRoles = { + [Role.ADMIN]: t('Administrator'), + [Role.VIEWER]: t('Viewer'), + [Role.OWNER]: t('Owner'), + }; + + /* + * Bug occurs from the Cunningham Datagrid component, when applying sorting + * on null values. Sanitize empty values to ensure consistent sorting functionality. + */ + const accesses = + data?.results?.map((access) => ({ + ...access, + localizedRole: localizedRoles[access.role], + user: { + ...access.user, + name: access.user.name, + email: access.user.email, + }, + })) || []; + + setAccesses(accesses); + }, [data?.results, t, isLoading]); + + useEffect(() => { + setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0); + }, [data?.count, pageSize, setPagesCount]); + + return ( + + {error && } + + + + + ); + }, + }, + { + headerName: t('Names'), + field: 'user.name', + }, + { + field: 'user.email', + headerName: t('Emails'), + }, + { + field: 'localizedRole', + headerName: t('Roles'), + }, + { + id: 'column-actions', + renderCell: ({ row }) => ( + + ), + }, + ]} + rows={accesses} + isLoading={isLoading} + pagination={pagination} + onSortModelChange={setSortModel} + sortModel={sortModel} + /> + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ChooseRole.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ChooseRole.tsx new file mode 100644 index 000000000..7e1b17fea --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ChooseRole.tsx @@ -0,0 +1,66 @@ +import { Radio, RadioGroup } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Role } from '../../domains'; + +interface ChooseRoleProps { + availableRoles: Role[]; + currentRole: Role; + disabled: boolean; + setRole: (role: Role) => void; +} + +export const ChooseRole = ({ + availableRoles, + disabled, + currentRole, + setRole, +}: ChooseRoleProps) => { + const { t } = useTranslation(); + const rolesToDisplay = Array.from(new Set([currentRole, ...availableRoles])); + + return ( + + {rolesToDisplay?.map((role) => { + switch (role) { + case Role.VIEWER: + return ( + setRole(evt.target.value as Role)} + defaultChecked={currentRole === Role.VIEWER} + disabled={disabled} + /> + ); + case Role.ADMIN: + return ( + setRole(evt.target.value as Role)} + defaultChecked={currentRole === Role.ADMIN} + disabled={disabled} + /> + ); + case Role.OWNER: + return ( + setRole(evt.target.value as Role)} + defaultChecked={currentRole === Role.OWNER} + disabled={disabled || currentRole !== Role.OWNER} + /> + ); + } + })} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalDelete.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalDelete.tsx new file mode 100644 index 000000000..e9f09d824 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalDelete.tsx @@ -0,0 +1,145 @@ +import { + Button, + ModalSize, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import { useRouter } from 'next/navigation'; + +import IconUser from '@/assets/icons/icon-user.svg'; +import { Box, Text, TextErrors } from '@/components'; +import { Modal } from '@/components/Modal'; +import { useCunninghamTheme } from '@/cunningham'; + +import { MailDomain, Role } from '../../domains'; +import { useDeleteMailDomainAccess } from '../api'; +import { useWhoAmI } from '../hooks/useWhoAmI'; +import { Access } from '../types'; + +export interface ModalDeleteProps { + access: Access; + currentRole: Role; + onClose: () => void; + mailDomain: MailDomain; +} + +export const ModalDelete = ({ + access, + onClose, + mailDomain, +}: ModalDeleteProps) => { + const { toast } = useToastProvider(); + const { colorsTokens } = useCunninghamTheme(); + const router = useRouter(); + + const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access); + const isNotAllowed = isOtherOwner || isLastOwner; + + const { + mutate: removeMailDomainAccess, + error: errorDeletion, + isError: isErrorUpdate, + } = useDeleteMailDomainAccess({ + onSuccess: () => { + toast( + t('The access has been removed from the domain'), + VariantType.SUCCESS, + { + duration: 4000, + }, + ); + + // If we remove ourselves, we redirect to the home page + // because we are no longer part of the domain + if (isMyself) { + router.push('/'); + } else { + onClose(); + } + }, + }); + + return ( + onClose()}> + {t('Cancel')} + + } + onClose={onClose} + rightActions={ + + } + size={ModalSize.MEDIUM} + title={ + + + {t('Remove this access from the domain')} + + + } + > + + + {t( + 'Are you sure you want to remove this access from the {{domain}} domain?', + { domain: mailDomain.name }, + )} + + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t( + 'You are the last owner, you cannot be removed from your domain.', + )} + {isOtherOwner && t('You cannot remove other owner.')} + + )} + + + + + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalRole.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalRole.tsx new file mode 100644 index 000000000..aed667d8b --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalRole.tsx @@ -0,0 +1,123 @@ +import { + Button, + ModalSize, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text, TextErrors } from '@/components'; +import { Modal } from '@/components/Modal'; +import { useUpdateMailDomainAccess } from '@/features/mail-domains/access-management'; + +import { Role } from '../../domains'; +import { useWhoAmI } from '../hooks/useWhoAmI'; +import { Access } from '../types'; + +import { ChooseRole } from './ChooseRole'; + +interface ModalRoleProps { + access: Access; + currentRole: Role; + onClose: () => void; + slug: string; +} + +export const ModalRole = ({ + access, + currentRole, + onClose, + slug, +}: ModalRoleProps) => { + const { t } = useTranslation(); + const [localRole, setLocalRole] = useState(access.role); + const { toast } = useToastProvider(); + const { + mutate: updateMailDomainAccess, + error: errorUpdate, + isError: isErrorUpdate, + isPending, + } = useUpdateMailDomainAccess({ + onSuccess: () => { + toast(t('The role has been updated'), VariantType.SUCCESS, { + duration: 4000, + }); + onClose(); + }, + }); + const { isLastOwner, isOtherOwner } = useWhoAmI(access); + + const isNotAllowed = isOtherOwner || isLastOwner; + + return ( + onClose()} + disabled={isPending} + > + {t('Cancel')} + + } + onClose={() => onClose()} + closeOnClickOutside + hideCloseButton + rightActions={ + + } + size={ModalSize.MEDIUM} + title={t('Update the role')} + > + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t( + 'You are the sole owner of this domain. Make another member the domain owner, before you can change your own role.', + )} + {isOtherOwner && t('You cannot update the role of other owner.')} + + )} + + + + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessAction.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessAction.test.tsx new file mode 100644 index 000000000..059e34473 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessAction.test.tsx @@ -0,0 +1,181 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AppWrapper } from '@/tests/utils'; + +import { MailDomain, Role } from '../../../domains'; +import { Access } from '../../types'; +import { AccessAction } from '../AccessAction'; +import { ModalDelete } from '../ModalDelete'; +import { ModalRole } from '../ModalRole'; + +jest.mock('../ModalRole', () => ({ + ModalRole: jest.fn(() =>
Mock ModalRole
), +})); + +jest.mock('../ModalDelete', () => ({ + ModalDelete: jest.fn(() =>
Mock ModalDelete
), +})); + +describe('AccessAction', () => { + const mockMailDomain: MailDomain = { + id: '1-1-1-1-1', + name: 'example.com', + slug: 'example-com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + abilities: { + get: true, + patch: true, + put: true, + post: true, + delete: true, + manage_accesses: true, + }, + }; + + const mockAccess: Access = { + id: '2-1-1-1-1', + role: Role.ADMIN, + user: { + id: '11', + name: 'username1', + email: 'user1@test.com', + }, + can_set_role_to: [Role.VIEWER, Role.ADMIN], + }; + + const renderAccessAction = ( + currentRole: Role = Role.ADMIN, + access: Access = mockAccess, + mailDomain = mockMailDomain, + ) => + render( + , + { wrapper: AppWrapper }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders nothing for unauthorized roles', () => { + renderAccessAction(Role.VIEWER); + + expect( + screen.queryByLabelText('Open the access options modal'), + ).not.toBeInTheDocument(); + + renderAccessAction(Role.ADMIN, { ...mockAccess, role: Role.OWNER }); + + expect( + screen.queryByLabelText('Open the access options modal'), + ).not.toBeInTheDocument(); + }); + + it('does not render "Update role" button when mailDomain lacks "put" and "patch" abilities', async () => { + const mailDomainWithoutUpdate = { + ...mockMailDomain, + abilities: { + ...mockMailDomain.abilities, + put: false, + patch: false, + }, + }; + + renderAccessAction(Role.ADMIN, mockAccess, mailDomainWithoutUpdate); + + const openButton = screen.getByLabelText('Open the access options modal'); + await userEvent.click(openButton); + + expect( + screen.queryByLabelText( + 'Open the modal to update the role of this access', + ), + ).not.toBeInTheDocument(); + }); + + it('opens the role update modal with correct props when "Update role" is clicked', async () => { + renderAccessAction(); + + const openButton = screen.getByLabelText('Open the access options modal'); + await userEvent.click(openButton); + + const updateRoleButton = screen.getByLabelText( + 'Open the modal to update the role of this access', + ); + await userEvent.click(updateRoleButton); + + expect(screen.getByText('Mock ModalRole')).toBeInTheDocument(); + expect(ModalRole).toHaveBeenCalledWith( + expect.objectContaining({ + access: mockAccess, + currentRole: Role.ADMIN, + slug: mockMailDomain.slug, + onClose: expect.any(Function), + }), + {}, + ); + }); + + it('does not render "Remove from domain" button when mailDomain lacks "delete" ability', async () => { + const mailDomainWithoutDelete = { + ...mockMailDomain, + abilities: { + ...mockMailDomain.abilities, + delete: false, + }, + }; + + renderAccessAction(Role.ADMIN, mockAccess, mailDomainWithoutDelete); + + const openButton = screen.getByLabelText('Open the access options modal'); + await userEvent.click(openButton); + + expect( + screen.queryByLabelText('Open the modal to delete this access'), + ).not.toBeInTheDocument(); + }); + + it('opens the delete modal with correct props when "Remove from domain" is clicked', async () => { + renderAccessAction(); + + const openButton = screen.getByLabelText('Open the access options modal'); + await userEvent.click(openButton); + + const removeButton = screen.getByLabelText( + 'Open the modal to delete this access', + ); + await userEvent.click(removeButton); + + expect(screen.getByText('Mock ModalDelete')).toBeInTheDocument(); + expect(ModalDelete).toHaveBeenCalledWith( + expect.objectContaining({ + access: mockAccess, + currentRole: Role.ADMIN, + mailDomain: mockMailDomain, + onClose: expect.any(Function), + }), + {}, + ); + }); + + it('toggles the DropButton', async () => { + renderAccessAction(); + + const openButton = screen.getByLabelText('Open the access options modal'); + expect(screen.queryByText('Update role')).toBeNull(); + + await userEvent.click(openButton); + expect(screen.getByText('Update role')).toBeInTheDocument(); + + // Close the dropdown + await userEvent.click(openButton); + expect(screen.queryByText('Update role')).toBeNull(); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesContent.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesContent.test.tsx new file mode 100644 index 000000000..b0b1d62c9 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesContent.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useRouter } from 'next/navigation'; + +import { AccessesGrid } from '@/features/mail-domains/access-management'; +import { AppWrapper } from '@/tests/utils'; + +import { MailDomain, Role } from '../../../domains'; +import { AccessesContent } from '../AccessesContent'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +jest.mock( + '@/features/mail-domains/access-management/components/AccessesGrid', + () => ({ + AccessesGrid: jest.fn(() =>
Mock AccessesGrid
), + }), +); + +jest.mock('@/features/mail-domains/assets/mail-domains-logo.svg', () => () => ( + +)); + +describe('AccessesContent', () => { + const mockRouterPush = jest.fn(); + + const mockMailDomain: MailDomain = { + id: '1-1-1-1-1', + name: 'example.com', + slug: 'example-com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + abilities: { + get: true, + patch: true, + put: true, + post: true, + delete: true, + manage_accesses: true, + }, + }; + + const renderAccessesContent = ( + currentRole: Role = Role.ADMIN, + mailDomain: MailDomain = mockMailDomain, + ) => + render( + , + { + wrapper: AppWrapper, + }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ + push: mockRouterPush, + }); + }); + + it('renders the top banner and accesses grid correctly', () => { + renderAccessesContent(); + + expect(screen.getByText(mockMailDomain.name)).toBeInTheDocument(); + expect(screen.getByTestId('mail-domains-logo')).toBeInTheDocument(); + expect(screen.getByText('Mock AccessesGrid')).toBeInTheDocument(); + }); + + it('renders the "Manage mailboxes" button when the user has access', () => { + renderAccessesContent(); + + const manageMailboxesButton = screen.getByRole('button', { + name: /Manage example.com domain mailboxes/, + }); + + expect(manageMailboxesButton).toBeInTheDocument(); + + expect(AccessesGrid).toHaveBeenCalledWith( + { currentRole: Role.ADMIN, mailDomain: mockMailDomain }, + {}, // adding this empty object is necessary to load jest context and that AccessesGrid is a mock + ); + }); + + it('does not render the "Manage mailboxes" button if the user lacks manage_accesses ability', () => { + const mailDomainWithoutAccess = { + ...mockMailDomain, + abilities: { + ...mockMailDomain.abilities, + manage_accesses: false, + }, + }; + + renderAccessesContent(Role.ADMIN, mailDomainWithoutAccess); + + expect( + screen.queryByRole('button', { + name: /Manage mailboxes/i, + }), + ).not.toBeInTheDocument(); + }); + + it('navigates to the mailboxes management page when "Manage mailboxes" is clicked', async () => { + renderAccessesContent(); + + const manageMailboxesButton = screen.getByRole('button', { + name: /Manage example.com domain mailboxes/, + }); + + await userEvent.click(manageMailboxesButton); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith(`/mail-domains/example-com/`); + }); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesGrid.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesGrid.test.tsx new file mode 100644 index 000000000..68e3775d8 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/AccessesGrid.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; + +import { AppWrapper } from '@/tests/utils'; + +import { MailDomain, Role } from '../../../domains'; +import { Access } from '../../types'; +import { AccessesGrid } from '../AccessesGrid'; + +jest.mock( + '@/features/mail-domains/access-management/components/AccessAction', + () => ({ + AccessAction: jest.fn(() =>
Mock AccessAction
), + }), +); + +jest.mock('@/assets/icons/icon-user.svg', () => () => ( + +)); + +const mockMailDomain: MailDomain = { + id: '1-1-1-1-1', + name: 'example.com', + slug: 'example-com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + abilities: { + manage_accesses: true, + get: true, + patch: true, + put: true, + post: true, + delete: false, + }, +}; + +const mockAccess: Access = { + id: '2-1-1-1-1', + role: Role.ADMIN, + user: { + id: '3-1-1-1-1', + name: 'username1', + email: 'user1@test.com', + }, + can_set_role_to: [Role.VIEWER, Role.ADMIN], +}; + +const mockAccessCreationResponse = { + count: 2, + results: [ + mockAccess, + { + id: '1-1-1-1-2', + role: Role.VIEWER, + user: { id: '22', name: 'username2', email: 'user2@test.com' }, + can_set_role_to: [Role.VIEWER], + }, + ], +}; + +describe('AccessesGrid', () => { + const renderAccessesGrid = (role: Role = Role.ADMIN) => + render(, { + wrapper: AppWrapper, + }); + + afterEach(() => { + fetchMock.restore(); + }); + + it('renders the grid with loading state', async () => { + fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', { + status: 200, + body: mockAccessCreationResponse, + }); + + renderAccessesGrid(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + await waitFor(() => + expect(screen.queryByRole('status')).not.toBeInTheDocument(), + ); + + expect(screen.getByText('username1')).toBeInTheDocument(); + expect(screen.getByText('username2')).toBeInTheDocument(); + }); + + it('renders an error message if the API call fails', async () => { + fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', { + status: 500, + body: { cause: ['Internal server error'] }, + }); + + renderAccessesGrid(); + + expect(await screen.findByText('Internal server error')).toBeVisible(); + }); + + it('applies sorting when a column header is clicked', async () => { + fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', { + status: 200, + body: mockAccessCreationResponse, + }); + + renderAccessesGrid(); + + await screen.findByText('username1'); + + fetchMock.getOnce( + 'end:/mail-domains/example-com/accesses/?page=1&ordering=user__name', + { + status: 200, + body: mockAccessCreationResponse, + }, + ); + + const nameHeader = screen.getByText('Names'); + await userEvent.click(nameHeader); + + // First load call, then sorting call + await waitFor(() => expect(fetchMock.calls()).toHaveLength(2)); + }); + + it('displays the correct columns and rows in the grid', async () => { + fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', { + status: 200, + body: mockAccessCreationResponse, + }); + + renderAccessesGrid(); + + // Waiting for the rows to render + await screen.findByText('Names'); + + expect(screen.getByText('Emails')).toBeInTheDocument(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + expect(screen.getByText('username1')).toBeInTheDocument(); + expect(screen.getByText('user1@test.com')).toBeInTheDocument(); + expect(screen.getByText('Administrator')).toBeInTheDocument(); + + expect(screen.getAllByText('Mock AccessAction')).toHaveLength(2); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ChooseRole.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ChooseRole.test.tsx new file mode 100644 index 000000000..57d92d147 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ChooseRole.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AppWrapper } from '@/tests/utils'; + +import { Role } from '../../../domains'; +import { ChooseRole } from '../ChooseRole'; + +describe('ChooseRole', () => { + const mockSetRole = jest.fn(); + + const renderChooseRole = ( + props: Partial> = {}, + ) => { + const defaultProps = { + availableRoles: [Role.VIEWER, Role.ADMIN], + currentRole: Role.ADMIN, + disabled: false, + setRole: mockSetRole, + ...props, + }; + + return render(, { wrapper: AppWrapper }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders available roles correctly', () => { + renderChooseRole(); + + expect(screen.getByLabelText('Viewer')).toBeInTheDocument(); + expect(screen.getByLabelText('Administrator')).toBeInTheDocument(); + }); + + it('sets default role checked correctly', () => { + renderChooseRole({ currentRole: Role.ADMIN }); + + const adminRadio: HTMLInputElement = screen.getByLabelText('Administrator'); + const viewerRadio: HTMLInputElement = screen.getByLabelText('Viewer'); + + expect(adminRadio).toBeChecked(); + expect(viewerRadio).not.toBeChecked(); + }); + + it('calls setRole when a new role is selected', async () => { + const user = userEvent.setup(); + + renderChooseRole(); + + await user.click(screen.getByLabelText('Viewer')); + + await waitFor(() => { + expect(mockSetRole).toHaveBeenCalledWith(Role.VIEWER); + }); + }); + + it('disables radio buttons when disabled prop is true', () => { + renderChooseRole({ disabled: true }); + + const viewerRadio: HTMLInputElement = screen.getByLabelText('Viewer'); + const adminRadio: HTMLInputElement = screen.getByLabelText('Administrator'); + + expect(viewerRadio).toBeDisabled(); + expect(adminRadio).toBeDisabled(); + }); + + it('disables owner radio button if current role is not owner', () => { + renderChooseRole({ + availableRoles: [Role.VIEWER, Role.ADMIN, Role.OWNER], + currentRole: Role.ADMIN, + }); + + const ownerRadio = screen.getByLabelText('Owner'); + + expect(ownerRadio).toBeDisabled(); + }); + + it('removes duplicates from availableRoles', () => { + renderChooseRole({ + availableRoles: [Role.VIEWER, Role.ADMIN, Role.VIEWER], + currentRole: Role.ADMIN, + }); + + const radios = screen.getAllByRole('radio'); + expect(radios.length).toBe(2); // Only two unique roles should be rendered + }); + + it('renders and checks owner role correctly when currentRole is owner', () => { + renderChooseRole({ + currentRole: Role.OWNER, + availableRoles: [Role.OWNER, Role.VIEWER, Role.ADMIN], + }); + + const ownerRadio: HTMLInputElement = screen.getByLabelText('Owner'); + + expect(ownerRadio).toBeInTheDocument(); + expect(ownerRadio).toBeChecked(); + }); + + it('renders no roles if availableRoles is empty', () => { + renderChooseRole({ + availableRoles: [], + currentRole: Role.ADMIN, + }); + + const radios = screen.queryAllByRole('radio'); + expect(radios.length).toBe(1); // Only the current role should be rendered + }); + + it.failing('sets aria-checked attribute correctly for selected roles', () => { + renderChooseRole({ currentRole: Role.ADMIN }); + + const adminRadio = screen.getByLabelText('Administrator'); + const viewerRadio = screen.getByLabelText('Viewer'); + + expect(adminRadio).toHaveAttribute('aria-checked', 'true'); + expect(viewerRadio).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalDelete.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalDelete.test.tsx new file mode 100644 index 000000000..e24d169db --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalDelete.test.tsx @@ -0,0 +1,228 @@ +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import { useRouter } from 'next/navigation'; + +import { Access } from '@/features/mail-domains/access-management'; +import { AppWrapper } from '@/tests/utils'; + +import { MailDomain, Role } from '../../../domains'; +import { useWhoAmI } from '../../hooks/useWhoAmI'; +import { ModalDelete, ModalDeleteProps } from '../ModalDelete'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('@openfun/cunningham-react', () => ({ + ...jest.requireActual('@openfun/cunningham-react'), + useToastProvider: jest.fn(), +})); + +jest.mock('../../hooks/useWhoAmI', () => ({ + useWhoAmI: jest.fn(), +})); + +describe('ModalDelete', () => { + const mockRouterPush = jest.fn(); + const mockClose = jest.fn(); + const mockToast = jest.fn(); + + const mockMailDomain: MailDomain = { + id: '1-1-1-1-1', + name: 'example.com', + slug: 'example-com', + status: 'enabled', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + abilities: { + get: true, + patch: true, + put: true, + post: true, + delete: true, + manage_accesses: true, + }, + }; + + const mockAccess: Access = { + id: '2-1-1-1-1', + user: { id: '3-1-1-1-1', name: 'username1', email: 'user1@test.com' }, + role: Role.ADMIN, + can_set_role_to: [Role.ADMIN, Role.VIEWER], + }; + + const renderModalDelete = (props: Partial = {}) => + render( + , + { + wrapper: AppWrapper, + }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + (useRouter as jest.Mock).mockReturnValue({ + push: mockRouterPush, + }); + (useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast }); + (useWhoAmI as jest.Mock).mockReturnValue({ + isMyself: false, + isLastOwner: false, + isOtherOwner: false, + }); + }); + + it('renders the modal with the correct content', () => { + renderModalDelete(); + + expect( + screen.getByText('Remove this access from the domain'), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to remove this access from the example.com domain?', + ), + ).toBeInTheDocument(); + expect(screen.getByText('username1')).toBeInTheDocument(); + }); + + it('calls onClose when Cancel is clicked', async () => { + renderModalDelete(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await userEvent.click(cancelButton); + + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + it('sends a delete request when "Remove from the domain" is clicked', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', { + status: 204, + }); + + renderModalDelete(); + + const removeButton = screen.getByRole('button', { + name: 'Remove from the domain', + }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(1); + }); + expect(fetchMock.lastUrl()).toContain( + '/mail-domains/example-com/accesses/2-1-1-1-1/', + ); + }); + + it('displays error message when API call fails', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', { + status: 500, + body: { cause: ['Failed to delete access'] }, + }); + + renderModalDelete(); + + const removeButton = screen.getByRole('button', { + name: 'Remove from the domain', + }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(screen.getByText('Failed to delete access')).toBeInTheDocument(); + }); + }); + + it('disables the remove button if the user is the last owner', () => { + (useWhoAmI as jest.Mock).mockReturnValue({ + isMyself: false, + isLastOwner: true, + isOtherOwner: false, + }); + + renderModalDelete(); + + const removeButton = screen.getByRole('button', { + name: 'Remove from the domain', + }); + expect(removeButton).toBeDisabled(); + expect( + screen.getByText( + 'You are the last owner, you cannot be removed from your domain.', + ), + ).toBeInTheDocument(); + }); + + it('disables the remove button if the user is not allowed to remove another owner', () => { + (useWhoAmI as jest.Mock).mockReturnValue({ + isMyself: false, + isLastOwner: false, + isOtherOwner: true, + }); + + renderModalDelete(); + + const removeButton = screen.getByRole('button', { + name: 'Remove from the domain', + }); + expect(removeButton).toBeDisabled(); + expect( + screen.getByText('You cannot remove other owner.'), + ).toBeInTheDocument(); + }); + + it('redirects to home page if user removes themselves', async () => { + (useWhoAmI as jest.Mock).mockReturnValue({ + isMyself: true, + isLastOwner: false, + isOtherOwner: false, + }); + + fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', { + status: 204, + }); + + renderModalDelete(); + + const removeButton = screen.getByRole('button', { + name: 'Remove from the domain', + }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith('/'); + }); + }); + + it('shows success toast and calls onClose after successful deletion', async () => { + fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', { + status: 204, + }); + + renderModalDelete(); + + const removeButton = screen.getByRole('button', { + name: 'Remove from the domain', + }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(1); + }); + expect(mockToast).toHaveBeenCalledWith( + 'The access has been removed from the domain', + VariantType.SUCCESS, + { duration: 4000 }, + ); + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalRole.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalRole.test.tsx new file mode 100644 index 000000000..7c42f39bc --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalRole.test.tsx @@ -0,0 +1,166 @@ +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import React from 'react'; + +import { AppWrapper } from '@/tests/utils'; + +import { Role } from '../../../domains'; +import { useWhoAmI } from '../../hooks/useWhoAmI'; +import { Access } from '../../types'; +import { ModalRole } from '../ModalRole'; + +jest.mock('@openfun/cunningham-react', () => ({ + ...jest.requireActual('@openfun/cunningham-react'), + useToastProvider: jest.fn(), +})); +jest.mock('../../hooks/useWhoAmI'); + +describe('ModalRole', () => { + const access: Access = { + id: '1-1-1-1-1-1', + role: Role.ADMIN, + user: { + id: '2-1-1-1-1-1', + name: 'username1', + email: 'user1@test.com', + }, + can_set_role_to: [Role.VIEWER, Role.ADMIN], + }; + + const mockOnClose = jest.fn(); + const mockToast = jest.fn(); + + const renderModalRole = ( + isLastOwner = false, + isOtherOwner = false, + props?: Partial>, + ) => { + (useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast }); + (useWhoAmI as jest.Mock).mockReturnValue({ + isLastOwner, + isOtherOwner, + }); + + return render( + , + { wrapper: AppWrapper }, + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + }); + + it('renders the modal with all elements', () => { + renderModalRole(); + + expect(screen.getByText('Update the role')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Validate/i }), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Radio buttons to update the roles'), + ).toBeInTheDocument(); + }); + + it('calls the close function when Cancel is clicked', async () => { + renderModalRole(); + + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await userEvent.click(cancelButton); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); + + it('updates the role and closes the modal when Validate is clicked', async () => { + fetchMock.patch(`end:mail-domains/domain-slug/accesses/1-1-1-1-1-1/`, { + status: 200, + body: { + id: '1-1-1-1-1-1', + role: Role.VIEWER, + }, + }); + + renderModalRole(); + + const validateButton = screen.getByRole('button', { name: /Validate/i }); + await userEvent.click(validateButton); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(1); + }); + expect(fetchMock.lastCall()?.[0]).toContain( + '/mail-domains/domain-slug/accesses/1-1-1-1-1-1/', + ); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + expect(mockToast).toHaveBeenCalledWith( + 'The role has been updated', + VariantType.SUCCESS, + { duration: 4000 }, + ); + }); + + it('disables the Validate button if the user is the last owner', () => { + renderModalRole(true, false); // isLastOwner = true, isOtherOwner = false + + const validateButton = screen.getByRole('button', { name: /Validate/i }); + expect(validateButton).toBeDisabled(); + expect( + screen.getByText(/You are the sole owner of this domain/i), + ).toBeInTheDocument(); + }); + + it('disables the Validate button if the user is another owner', () => { + renderModalRole(false, true); // isLastOwner = false, isOtherOwner = true + + const validateButton = screen.getByRole('button', { name: /Validate/i }); + expect(validateButton).toBeDisabled(); + expect( + screen.getByText(/You cannot update the role of other owner/i), + ).toBeInTheDocument(); + }); + + it('shows error message when update fails', async () => { + fetchMock.patch(`end:mail-domains/domain-slug/accesses/1-1-1-1-1-1/`, { + status: 400, + body: { + cause: ['Error updating role'], + }, + }); + + renderModalRole(); + + const validateButton = screen.getByRole('button', { name: /Validate/i }); + await userEvent.click(validateButton); + + await waitFor(() => { + expect(fetchMock.calls().length).toBe(1); + }); + + expect(screen.getByText('Error updating role')).toBeInTheDocument(); + }); + + it('displays the available roles and ensures no duplicates', () => { + renderModalRole(); + + const radioButtons = screen.getAllByRole('radio'); + expect(radioButtons.length).toBe(2); // Only two roles: Viewer and Admin + expect(screen.getByLabelText('Administrator')).toBeInTheDocument(); + expect(screen.getByLabelText('Viewer')).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/index.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/index.ts new file mode 100644 index 000000000..22d144167 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/index.ts @@ -0,0 +1,5 @@ +export * from './AccessesContent'; +export * from './AccessesGrid'; +export * from './ChooseRole'; +export * from './ModalRole'; +export * from './ModalDelete'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/conf.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/conf.ts new file mode 100644 index 000000000..bfab90671 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/conf.ts @@ -0,0 +1 @@ +export const PAGE_SIZE = 20; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/__tests__/useWhoAmI.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/__tests__/useWhoAmI.test.tsx new file mode 100644 index 000000000..e2f4f5a03 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/__tests__/useWhoAmI.test.tsx @@ -0,0 +1,86 @@ +import { renderHook } from '@testing-library/react'; + +import { useAuthStore } from '@/core/auth/useAuthStore'; + +import { Role } from '../../../domains'; +import { useWhoAmI } from '../../hooks/useWhoAmI'; +import { Access } from '../../types'; + +jest.mock('@/core/auth/useAuthStore'); + +const mockAccess: Access = { + id: '1-1-1-1-1', + user: { + id: '2-1-1-1-1', + name: 'User One', + email: 'user1@example.com', + }, + role: Role.ADMIN, + can_set_role_to: [Role.VIEWER, Role.ADMIN], +}; + +describe('useWhoAmI', () => { + beforeEach(() => { + (useAuthStore as unknown as jest.Mock).mockReturnValue({ + authenticated: true, + userData: { + id: '2-1-1-1-1', + name: 'Current User', + email: 'currentuser@example.com', + }, + }); + }); + + const renderUseWhoAmI = (access: Access) => + renderHook(() => useWhoAmI(access)); + + it('identifies if the current user is themselves', () => { + const { result } = renderUseWhoAmI(mockAccess); + expect(result.current.isMyself).toBeTruthy(); + }); + + it('identifies if the current user is not themselves', () => { + const { result } = renderUseWhoAmI({ + ...mockAccess, + user: { ...mockAccess.user, id: '2-1-1-1-2' }, + }); + expect(result.current.isMyself).toBeFalsy(); + }); + + it('identifies if the current user is the last owner', () => { + const accessAsLastOwner = { + ...mockAccess, + role: Role.OWNER, + can_set_role_to: [], + }; + const { result } = renderUseWhoAmI(accessAsLastOwner); + expect(result.current.isLastOwner).toBeTruthy(); + }); + + it('identifies if the current user is not the last owner', () => { + const accessAsNonOwner = { ...mockAccess, role: Role.ADMIN }; + const { result } = renderUseWhoAmI(accessAsNonOwner); + expect(result.current.isLastOwner).toBeFalsy(); + }); + + it('identifies if the current user is another owner', () => { + const accessOfOtherOwner = { + ...mockAccess, + role: Role.OWNER, + user: { ...mockAccess.user, id: '2-1-1-1-2' }, + }; + + const { result } = renderUseWhoAmI(accessOfOtherOwner); + expect(result.current.isOtherOwner).toBeTruthy(); + }); + + it('identifies if the current user is not another owner', () => { + const nonOwnerAccess = { + ...mockAccess, + role: Role.ADMIN, + user: { ...mockAccess.user, id: '2-1-1-1-2' }, + }; + const { result } = renderUseWhoAmI(nonOwnerAccess); + expect(result.current.isOtherOwner).toBeFalsy(); + }); +}); diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/useWhoAmI.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/useWhoAmI.tsx new file mode 100644 index 000000000..41827c0eb --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/hooks/useWhoAmI.tsx @@ -0,0 +1,22 @@ +import { useAuthStore } from '@/core/auth'; + +import { Role } from '../../domains/types'; +import { Access } from '../types'; + +export const useWhoAmI = (access: Access) => { + const { userData } = useAuthStore(); + + const isMyself = userData?.id === access.user.id; + const rolesAllowed = access.can_set_role_to; + + const isLastOwner = + !rolesAllowed.length && access.role === Role.OWNER && isMyself; + + const isOtherOwner = access.role === Role.OWNER && userData?.id && !isMyself; + + return { + isLastOwner, + isOtherOwner, + isMyself, + }; +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/index.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/index.ts new file mode 100644 index 000000000..314dad0cd --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './components'; +export * from './types'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/types.ts b/src/frontend/apps/desk/src/features/mail-domains/access-management/types.ts new file mode 100644 index 000000000..4e0bc16ae --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/types.ts @@ -0,0 +1,12 @@ +import { UUID } from 'crypto'; + +import { User } from '@/core/auth'; + +import { Role } from '../domains/types'; + +export interface Access { + id: UUID; + role: Role; + user: User; + can_set_role_to: Role[]; +} diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/index.ts b/src/frontend/apps/desk/src/features/mail-domains/components/panel/index.ts deleted file mode 100644 index 8960d84f6..000000000 --- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Panel'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalAddMailDomain.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/__tests__/ModalAddMailDomain.test.tsx similarity index 99% rename from src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalAddMailDomain.test.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/__tests__/ModalAddMailDomain.test.tsx index e585ba930..f09c6156d 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/__tests__/ModalAddMailDomain.test.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/__tests__/ModalAddMailDomain.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { AppWrapper } from '@/tests/utils'; -import { ModalAddMailDomain } from '../ModalAddMailDomain'; +import { ModalAddMailDomain } from '../components'; const mockPush = jest.fn(); jest.mock('next/navigation', () => ({ diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/index.tsx similarity index 60% rename from src/frontend/apps/desk/src/features/mail-domains/api/index.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/index.tsx index 61a928cca..dc54b8a55 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/api/index.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/index.tsx @@ -1,5 +1,3 @@ export * from './useMailDomains'; export * from './useMailDomain'; -export * from './useCreateMailbox'; -export * from './useMailboxes'; export * from './useAddMailDomain'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useAddMailDomain.tsx similarity index 95% rename from src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/useAddMailDomain.tsx index 60c81c501..c76d3b648 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useAddMailDomain.tsx @@ -1,7 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { MailDomain } from '@/features/mail-domains'; + +import { MailDomain } from '../types'; import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomain.tsx similarity index 95% rename from src/frontend/apps/desk/src/features/mail-domains/api/useMailDomain.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomain.tsx index 95ef86a80..a07437fab 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomain.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomain.tsx @@ -25,7 +25,7 @@ export const getMailDomain = async ({ return response.json() as Promise; }; -const KEY_MAIL_DOMAIN = 'mail-domain'; +export const KEY_MAIL_DOMAIN = 'mail-domain'; export function useMailDomain( param: MailDomainParams, diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomains.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomains.tsx similarity index 96% rename from src/frontend/apps/desk/src/features/mail-domains/api/useMailDomains.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomains.tsx index 08ca73020..0a491b9c1 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/api/useMailDomains.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/api/useMailDomains.tsx @@ -6,7 +6,8 @@ import { } from '@tanstack/react-query'; import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; -import { MailDomain } from '@/features/mail-domains/types'; + +import { MailDomain } from '../types'; type MailDomainsResponse = APIList; diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsLayout.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainsLayout.tsx similarity index 89% rename from src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsLayout.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainsLayout.tsx index 7d1e14acc..b68405e11 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsLayout.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainsLayout.tsx @@ -3,7 +3,8 @@ import { PropsWithChildren } from 'react'; import { Box } from '@/components'; import { MainLayout } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; -import { Panel } from '@/features/mail-domains/components/panel'; + +import { Panel } from './panel'; export function MailDomainsLayout({ children }: PropsWithChildren) { const { colorsTokens } = useCunninghamTheme(); diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/ModalAddMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/ModalAddMailDomain.tsx similarity index 97% rename from src/frontend/apps/desk/src/features/mail-domains/components/ModalAddMailDomain.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/ModalAddMailDomain.tsx index f89679115..62f07c4e9 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/ModalAddMailDomain.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/ModalAddMailDomain.tsx @@ -9,9 +9,9 @@ import { z } from 'zod'; import { parseAPIError } from '@/api/parseAPIError'; import { Box, Text, TextErrors } from '@/components'; import { Modal } from '@/components/Modal'; -import { useAddMailDomain } from '@/features/mail-domains'; -import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg'; +import { default as MailDomainsLogo } from '../../assets/mail-domains-logo.svg'; +import { useAddMailDomain } from '../api'; const FORM_ID = 'form-add-mail-domain'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/domains/components/index.ts b/src/frontend/apps/desk/src/features/mail-domains/domains/components/index.ts new file mode 100644 index 000000000..18a8a4915 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/index.ts @@ -0,0 +1,3 @@ +export * from './ModalAddMailDomain'; +export * from './MailDomainsLayout'; +export * from './panel'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/ItemList.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/ItemList.tsx similarity index 91% rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/ItemList.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/ItemList.tsx index 184559fe0..43447185d 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/ItemList.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/ItemList.tsx @@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next'; import { Box, Text } from '@/components'; import { InfiniteScroll } from '@/components/InfiniteScroll'; -import { MailDomain } from '@/features/mail-domains'; -import { useMailDomains } from '@/features/mail-domains/api/useMailDomains'; -import { useMailDomainsStore } from '@/features/mail-domains/store/useMailDomainsStore'; +import { + MailDomain, + useMailDomains, + useMailDomainsStore, +} from '@/features/mail-domains/domains'; import { PanelMailDomains } from './PanelItem'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/Panel.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/Panel.tsx similarity index 98% rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/Panel.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/Panel.tsx index 44e67d27b..2ce61f6ef 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/Panel.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/Panel.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import IconOpenClose from '@/assets/icons/icon-open-close.svg'; import { Box, BoxButton, Text } from '@/components'; -import { useConfigStore } from '@/core/'; +import { useConfigStore } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { ItemList } from './ItemList'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelActions.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelActions.tsx similarity index 90% rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelActions.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelActions.tsx index d04b632ed..2edb32ebc 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelActions.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelActions.tsx @@ -5,8 +5,10 @@ import IconAdd from '@/assets/icons/icon-add.svg'; import IconSort from '@/assets/icons/icon-sort.svg'; import { Box, BoxButton, StyledLink, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { EnumMailDomainsOrdering } from '@/features/mail-domains'; -import { useMailDomainsStore } from '@/features/mail-domains/store/useMailDomainsStore'; +import { + EnumMailDomainsOrdering, + useMailDomainsStore, +} from '@/features/mail-domains/domains'; export const PanelActions = () => { const { t } = useTranslation(); diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelItem.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelItem.tsx similarity index 98% rename from src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelItem.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelItem.tsx index 1065ce4f4..4f255e896 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/panel/PanelItem.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/PanelItem.tsx @@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next'; import { Box, StyledLink, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { MailDomain } from '@/features/mail-domains'; import IconMailDomains from '@/features/mail-domains/assets/icon-mail-domains.svg'; +import { MailDomain } from '../../index'; + interface MailDomainProps { mailDomain: MailDomain; } diff --git a/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/index.ts b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/index.ts new file mode 100644 index 000000000..63813bbb4 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/panel/index.ts @@ -0,0 +1,4 @@ +export * from './Panel'; +export * from './ItemList'; +export * from './PanelActions'; +export * from './PanelItem'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/index.tsx similarity index 100% rename from src/frontend/apps/desk/src/features/mail-domains/index.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/index.tsx index a04223573..86ad49e7c 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/index.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/index.tsx @@ -1,4 +1,4 @@ export * from './components'; -export * from './types'; export * from './api'; export * from './store'; +export * from './types'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/store/index.ts b/src/frontend/apps/desk/src/features/mail-domains/domains/store/index.ts similarity index 100% rename from src/frontend/apps/desk/src/features/mail-domains/store/index.ts rename to src/frontend/apps/desk/src/features/mail-domains/domains/store/index.ts diff --git a/src/frontend/apps/desk/src/features/mail-domains/store/useMailDomainsStore.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/store/useMailDomainsStore.tsx similarity index 86% rename from src/frontend/apps/desk/src/features/mail-domains/store/useMailDomainsStore.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/store/useMailDomainsStore.tsx index dc6e195b5..0b27cd2b0 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/store/useMailDomainsStore.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/store/useMailDomainsStore.tsx @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { EnumMailDomainsOrdering } from '../api/useMailDomains'; +import { EnumMailDomainsOrdering } from '@/features/mail-domains/domains/api'; interface MailDomainsStore { ordering: EnumMailDomainsOrdering; diff --git a/src/frontend/apps/desk/src/features/mail-domains/types.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/types.ts similarity index 71% rename from src/frontend/apps/desk/src/features/mail-domains/types.tsx rename to src/frontend/apps/desk/src/features/mail-domains/domains/types.ts index 039c57d61..13b9131fd 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/types.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/types.ts @@ -1,5 +1,4 @@ import { UUID } from 'crypto'; - export interface MailDomain { id: UUID; name: string; @@ -17,10 +16,8 @@ export interface MailDomain { }; } -export interface MailDomainMailbox { - id: UUID; - local_part: string; - first_name: string; - last_name: string; - secondary_email: string; +export enum Role { + ADMIN = 'administrator', + OWNER = 'owner', + VIEWER = 'viewer', } diff --git a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/index.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/index.tsx new file mode 100644 index 000000000..f394a921d --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/index.tsx @@ -0,0 +1,2 @@ +export * from './useCreateMailbox'; +export * from './useMailboxes'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useCreateMailbox.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useCreateMailbox.tsx similarity index 100% rename from src/frontend/apps/desk/src/features/mail-domains/api/useCreateMailbox.tsx rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useCreateMailbox.tsx diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useMailboxes.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useMailboxes.tsx similarity index 100% rename from src/frontend/apps/desk/src/features/mail-domains/api/useMailboxes.tsx rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/api/useMailboxes.tsx diff --git a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsContent.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/MailDomainsContent.tsx similarity index 84% rename from src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsContent.tsx rename to src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/MailDomainsContent.tsx index 186c2a4bf..71edf2175 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/components/MailDomainsContent.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/MailDomainsContent.tsx @@ -9,17 +9,18 @@ import { VariantType, usePagination, } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Card, Text, TextErrors, TextStyled } from '@/components'; +import { ModalCreateMailbox } from '@/features/mail-domains/mailboxes'; +import { default as MailDomainsLogo } from '../../assets/mail-domains-logo.svg'; +import { PAGE_SIZE } from '../../conf'; +import { MailDomain } from '../../domains/types'; import { useMailboxes } from '../api/useMailboxes'; -import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg'; -import { PAGE_SIZE } from '../conf'; -import { MailDomain, MailDomainMailbox } from '../types'; - -import { ModalCreateMailbox } from './ModalCreateMailbox'; +import { MailDomainMailbox } from '../types'; export type ViewMailbox = { name: string; @@ -102,6 +103,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) { $padding={{ bottom: 'small' }} $margin={{ all: 'big', top: 'none' }} $overflow="auto" + aria-label={t('Mailboxes list card')} > {error && } @@ -151,6 +153,7 @@ const TopBanner = ({ mailDomain: MailDomain; showMailBoxCreationForm: (value: boolean) => void; }) => { + const router = useRouter(); const { t } = useTranslation(); return ( @@ -165,7 +168,7 @@ const TopBanner = ({ $gap="2.25rem" $justify="space-between" > - +