diff --git a/CHANGELOG.md b/CHANGELOG.md index aba4bd4..e364293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(frontend) feature modal add new access role to domain - ✨(api) allow invitations for domain management #708 ## [1.13.1] - 2025-03-04 diff --git a/Makefile b/Makefile index 846e321..cebc85f 100644 --- a/Makefile +++ b/Makefile @@ -156,6 +156,10 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f bin/pylint --diff-only=origin/main .PHONY: lint-pylint +lint-front: + cd $(PATH_FRONT) && yarn lint +.PHONY: lint-front + test: ## run project tests @$(MAKE) test-back-parallel .PHONY: test diff --git a/src/frontend/apps/desk/src/cunningham/cunningham-style.css b/src/frontend/apps/desk/src/cunningham/cunningham-style.css index 01dc71b..d201694 100644 --- a/src/frontend/apps/desk/src/cunningham/cunningham-style.css +++ b/src/frontend/apps/desk/src/cunningham/cunningham-style.css @@ -327,6 +327,10 @@ input:-webkit-autofill:focus { outline: var(--c--theme--colors--primary-600) solid 2px; } +.c__radio input::before { + box-shadow: inset 1em 1em var(--c--theme--colors--primary-600); +} + /** * Button */ 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 index 4f7ef78..9090bd6 100644 --- 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 @@ -1,3 +1,5 @@ export * from './useMailDomainAccesses'; export * from './useUpdateMailDomainAccess'; +export * from './useCreateMailDomainAccess'; export * from './useDeleteMailDomainAccess'; +export * from './useCreateInvitation'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useCreateInvitation.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useCreateInvitation.tsx new file mode 100644 index 0000000..cfc71b2 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useCreateInvitation.tsx @@ -0,0 +1,48 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { User } from '@/core/auth'; +import { Invitation, OptionType } from '@/features/teams/member-add/types'; + +import { MailDomain, Role } from '../../domains'; + +interface CreateInvitationParams { + email: User['email']; + role: Role; + mailDomainSlug: MailDomain['slug']; +} + +export const createInvitation = async ({ + email, + role, + mailDomainSlug, +}: CreateInvitationParams): Promise => { + const response = await fetchAPI( + `mail-domains/${mailDomainSlug}/invitations/`, + { + method: 'POST', + body: JSON.stringify({ + email, + role, + }), + }, + ); + + if (!response.ok) { + throw new APIError( + `Failed to create the invitation for ${email}`, + await errorCauses(response, { + value: email, + type: OptionType.INVITATION, + }), + ); + } + + return response.json() as Promise; +}; + +export function useCreateInvitation() { + return useMutation({ + mutationFn: createInvitation, + }); +} diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useCreateMailDomainAccess.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useCreateMailDomainAccess.tsx new file mode 100644 index 0000000..4c2ec4a --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useCreateMailDomainAccess.tsx @@ -0,0 +1,62 @@ +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 CreateMailDomainAccessProps { + slug: string; + user: string; + role: Role; +} + +export const createMailDomainAccess = async ({ + slug, + user, + role, +}: CreateMailDomainAccessProps): Promise => { + const response = await fetchAPI(`mail-domains/${slug}/accesses/`, { + method: 'POST', + body: JSON.stringify({ user, role }), + }); + + if (!response.ok) { + throw new APIError('Failed to create role', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +type UseCreateMailDomainAccessOptions = UseMutationOptions< + Access, + APIError, + CreateMailDomainAccessProps +>; + +export const useCreateMailDomainAccess = ( + options?: UseCreateMailDomainAccessOptions, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createMailDomainAccess, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES], + }); + void queryClient.invalidateQueries({ queryKey: [KEY_MAIL_DOMAIN] }); + options?.onSuccess?.(data, variables, context); + }, + onError: (error, variables, context) => { + options?.onError?.(error, variables, context); + }, + }); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUsers.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUsers.tsx new file mode 100644 index 0000000..ba26a4f --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useUsers.tsx @@ -0,0 +1,41 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { User } from '@/core/auth'; +import { MailDomain } from '@/features/mail-domains/domains/types'; + +export type UsersParams = { + query: string; + mailDomain: MailDomain['slug']; +}; + +type UsersResponse = User[]; + +export const getUsers = async ({ + query, + mailDomain, +}: UsersParams): Promise => { + const response = await fetchAPI( + `mail-domains/${mailDomain}/accesses/users/?q=${query}`, + ); + + if (!response.ok) { + throw new APIError('Failed to get the users', await errorCauses(response)); + } + + const res = (await response.json()) as User[]; + return res; +}; + +export const KEY_LIST_USER = 'users'; + +export function useUsers( + param: UsersParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_USER, param], + queryFn: () => getUsers(param), + ...queryConfig, + }); +} 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 index d02ae33..84e5f44 100644 --- 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 @@ -1,17 +1,56 @@ -import React from 'react'; +import { Button } from '@openfun/cunningham-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box } from '@/components'; import { AccessesGrid } from '@/features/mail-domains/access-management/components/AccessesGrid'; import { MailDomain, Role } from '../../domains'; +import { ModalCreateAccess } from './ModalCreateAccess'; + export const AccessesContent = ({ mailDomain, currentRole, }: { mailDomain: MailDomain; currentRole: Role; -}) => ( - <> - - -); +}) => { + const { t } = useTranslation(); + + const [isModalAccessOpen, setIsModalAccessOpen] = useState(false); + + return ( + <> + + + {mailDomain?.abilities.post && ( + + )} + + + + {isModalAccessOpen && mailDomain && ( + setIsModalAccessOpen(false)} + /> + )} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalCreateAccess.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalCreateAccess.tsx new file mode 100644 index 0000000..86e05d9 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/ModalCreateAccess.tsx @@ -0,0 +1,165 @@ +import { + Button, + ModalSize, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { APIError } from '@/api'; +import { Box, Text } from '@/components'; +import { Modal } from '@/components/Modal'; +import { useCreateMailDomainAccess } from '@/features/mail-domains/access-management'; +import { + OptionSelect, + OptionType, + isOptionNewMember, +} from '@/features/teams/member-add/types'; + +import { MailDomain, Role } from '../../domains'; +import { useCreateInvitation } from '../api'; + +import { ChooseRole } from './ChooseRole'; +import { OptionsSelect, SearchMembers } from './SearchMembers'; + +interface ModalCreateAccessProps { + mailDomain: MailDomain; + currentRole: Role; + onClose: () => void; +} + +type APIErrorMember = APIError<{ + value: string; + type: OptionType; +}>; + +export const ModalCreateAccess = ({ + mailDomain, + currentRole, + onClose, +}: ModalCreateAccessProps) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const [selectedMembers, setSelectedMembers] = useState([]); + const [role, setRole] = useState(Role.VIEWER); + + const createInvitation = useCreateInvitation(); + const { mutateAsync: createMailDomainAccess } = useCreateMailDomainAccess(); + + const onSuccess = (option: OptionSelect) => { + const message = !isOptionNewMember(option) + ? t('Invitation sent to {{email}}', { + email: option.value.email, + }) + : t('Access added to {{name}}', { + name: option.value.name, + }); + + toast(message, VariantType.SUCCESS); + }; + + const onError = (dataError: APIErrorMember['data']) => { + const messageError = + dataError?.type === OptionType.INVITATION + ? t('Failed to create the invitation') + : t('Failed to add access'); + toast(messageError, VariantType.ERROR); + }; + + const switchActions = (selectedMembers: OptionsSelect) => + selectedMembers.map(async (selectedMember) => { + switch (selectedMember.type) { + case OptionType.INVITATION: + await createInvitation.mutateAsync({ + email: selectedMember.value.email, + mailDomainSlug: mailDomain.slug, + role, + }); + break; + + default: + await createMailDomainAccess({ + slug: mailDomain.slug, + user: selectedMember.value.id, + role, + }); + break; + } + + return selectedMember; + }); + + const handleValidate = async () => { + const settledPromises = await Promise.allSettled( + switchActions(selectedMembers), + ); + + settledPromises.forEach((settledPromise) => { + switch (settledPromise.status) { + case 'rejected': + onError((settledPromise.reason as APIErrorMember).data); + break; + + case 'fulfilled': + onSuccess(settledPromise.value); + break; + } + onClose(); + }); + }; + + return ( + + {t('Cancel')} + + } + onClose={onClose} + closeOnClickOutside + hideCloseButton + rightActions={ + + } + size={ModalSize.MEDIUM} + title={ + + + {t('Add a new access')} + + + } + > + + + {selectedMembers.length > 0 && ( + + + {t('Choose a role')} + + + + )} + + + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/SearchMembers.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/SearchMembers.tsx new file mode 100644 index 0000000..a8e22e0 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/SearchMembers.tsx @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Options } from 'react-select'; +import AsyncSelect from 'react-select/async'; + +import { MailDomain } from '@/features/mail-domains/domains/types'; +import { OptionSelect, OptionType } from '@/features/teams/member-add/types'; +import { isValidEmail } from '@/utils'; + +import { useUsers } from '../api/useUsers'; + +export type OptionsSelect = Options; + +interface SearchMembersProps { + mailDomain: MailDomain; + selectedMembers: OptionsSelect; + setSelectedMembers: (value: OptionsSelect) => void; + disabled?: boolean; +} + +export const SearchMembers = ({ + mailDomain, + selectedMembers, + setSelectedMembers, + disabled, +}: SearchMembersProps) => { + const { t } = useTranslation(); + const [input, setInput] = useState(''); + const [userQuery, setUserQuery] = useState(''); + const resolveOptionsRef = useRef<((value: OptionsSelect) => void) | null>( + null, + ); + const { data } = useUsers({ + query: userQuery, + mailDomain: mailDomain.slug, + }); + + const options = data; + + useEffect(() => { + if (!resolveOptionsRef.current || !options) { + return; + } + + const optionsFiltered = options.filter( + (user) => + !selectedMembers?.find( + (selectedUser) => selectedUser.value.email === user.email, + ), + ); + + let users: OptionsSelect = optionsFiltered.map((user) => ({ + value: user, + label: user.name || user.email, + type: OptionType.NEW_MEMBER, + })); + + if (userQuery && isValidEmail(userQuery)) { + const isFoundUser = !!optionsFiltered.find( + (user) => user.email === userQuery, + ); + const isFoundEmail = !!selectedMembers.find( + (selectedMember) => selectedMember.value.email === userQuery, + ); + + if (!isFoundUser && !isFoundEmail) { + users = [ + { + value: { email: userQuery }, + label: userQuery, + type: OptionType.INVITATION, + }, + ]; + } + } + + resolveOptionsRef.current(users); + resolveOptionsRef.current = null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options, selectedMembers]); + + const loadOptions = (): Promise => { + return new Promise((resolve) => { + resolveOptionsRef.current = resolve; + }); + }; + + const timeout = useRef(null); + const onInputChangeHandle = useCallback((newValue: string) => { + setInput(newValue); + if (timeout.current) { + clearTimeout(timeout.current); + } + + timeout.current = setTimeout(() => { + setUserQuery(newValue); + }, 1000); + }, []); + + return ( + + t('Invite new members with roles', { name: mailDomain.name }) + } + onChange={(value) => { + setInput(''); + setUserQuery(''); + setSelectedMembers(value); + }} + /> + ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalCreateAccess.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalCreateAccess.test.tsx new file mode 100644 index 0000000..25f8a08 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/__tests__/ModalCreateAccess.test.tsx @@ -0,0 +1,87 @@ +import { 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 { useCreateMailDomainAccess } from '@/features/mail-domains/access-management'; +import { AppWrapper } from '@/tests/utils'; + +import { MailDomain, Role } from '../../../domains'; +import { ModalCreateAccess } from '../ModalCreateAccess'; + +const domain: MailDomain = { + id: '897-9879-986789-89798-897', + name: 'Domain test', + created_at: '121212', + updated_at: '121212', + slug: 'test-domain', + status: 'pending', + support_email: 'sfs@test-domain.fr', + abilities: { + get: true, + patch: true, + put: true, + post: true, + delete: true, + manage_accesses: true, + }, +}; + +jest.mock('@openfun/cunningham-react', () => ({ + ...jest.requireActual('@openfun/cunningham-react'), + useToastProvider: jest.fn(), +})); + +jest.mock('../../api', () => ({ + useCreateInvitation: jest.fn(() => ({ mutateAsync: jest.fn() })), +})); + +jest.mock('@/features/mail-domains/access-management', () => ({ + useCreateMailDomainAccess: jest.fn(), +})); + +describe('ModalCreateAccess', () => { + const mockOnClose = jest.fn(); + const mockToast = jest.fn(); + const mockCreateMailDomainAccess = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + (useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast }); + + (useCreateMailDomainAccess as jest.Mock).mockReturnValue({ + mutateAsync: mockCreateMailDomainAccess, + }); + }); + + const renderModalCreateAccess = () => { + return render( + , + { wrapper: AppWrapper }, + ); + }; + + it('renders the modal with all elements', () => { + renderModalCreateAccess(); + expect(screen.getByText('Add a new access')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Add to domain/i }), + ).toBeInTheDocument(); + }); + + it('calls onClose when Cancel is clicked', async () => { + renderModalCreateAccess(); + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await userEvent.click(cancelButton); + await waitFor(() => expect(mockOnClose).toHaveBeenCalledTimes(1), { + timeout: 3000, + }); + }); +}); diff --git a/src/frontend/apps/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json index 1e48bc3..c8f8d2b 100644 --- a/src/frontend/apps/desk/src/i18n/translations.json +++ b/src/frontend/apps/desk/src/i18n/translations.json @@ -22,6 +22,7 @@ "0 group to display.": "0 groupe à afficher.", "Access icon": "Icône d'accès", "Access management": "Gestion des rôles", + "Access added to {{name}}": "Accès accordés à {{name}}", "Accesses list card": "Carte de la liste des accès", "Accessibility statement": "Déclaration d'accessibilité", "Accessibility: non-compliant": "Accessibilité : non conforme", @@ -29,11 +30,13 @@ "Actions required detail": "Détail des actions requises", "Add a mail domain": "Ajouter un nom de domaine", "Add a member": "Ajouter un membre", + "Add a new access": "Ajouter un nouveau rôle", "Add a team": "Ajouter un groupe", "Add members to the team": "Ajouter des membres à l'équipe", "Add the domain": "Ajouter le domaine", "Add the following DNS values:": "Ajouter les valeurs DNS suivantes :", "Add to group": "Ajouter au groupe", + "Add to domain": "Ajouter au domaine", "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", @@ -85,6 +88,7 @@ "Enable mailbox": "Activer la boîte mail", "Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné", "Example: saint-laurent.fr": "Exemple : saint-laurent.fr", + "Failed to add access": "Impossible d'ajouter les accès", "Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe", "Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}", "Failed to fetch domain data": "Impossible de récupérer les données du domaine", @@ -104,7 +108,8 @@ "Image 404 page not found": "Image 404 page introuvable", "Improvement and contact": "Amélioration et contact", "Invitation sent to {{email}}": "Invitation envoyée à {{email}}", - "Invite new members to {{teamName}}": "Invitez de nouveaux membres à rejoindre {{teamName}}", + "Invite new members to {{teamName}}": "Inviter de nouveaux membres à rejoindre {{teamName}}", + "Invite new members with roles": "Inviter de nouveaux membres avec un rôle", "It must not contain spaces, accents or special characters (except \".\" or \"-\"). E.g.: jean.dupont": "Il ne doit pas contenir d'espaces, d'accents ou de caractères spéciaux (excepté \".\" ou \"-\"). Ex. : jean.dupont", "It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.", "It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "C'est vrai, vous n'avez pas à cliquer sur un bloc qui couvre la moitié de la page pour dire que vous acceptez le placement de cookies — même si vous ne savez pas ce que cela signifie !", @@ -168,6 +173,7 @@ "Roles": "Rôles", "Régie": "Régie", "Search new members (name or email)": "Rechercher de nouveaux membres (nom ou email)", + "Search for members to assign them a role (name or email)": "Rechercher des membres afin de leur attribuer un rôle (nom ou email)", "Secondary email address": "Adresse e-mail secondaire", "Send a letter by post (free of charge, no stamp needed):": "Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre) :", "Something bad happens, please refresh the page.": "Une erreur inattendue s'est produite, rechargez la page.",