From bb5bc49d8a80969fd18d6342acf99eb83129f4fa Mon Sep 17 00:00:00 2001 From: pprevautel Date: Tue, 26 Nov 2024 14:01:43 +0100 Subject: [PATCH] gestion des membres, invitation, emailplanners ... --- assets/@types/app_espaceco.ts | 29 ++- assets/espaceco/api/community.ts | 16 ++ assets/espaceco/api/users.ts | 11 +- .../pages/communities/MemberInvitation.tsx | 223 ++++++++++++++++ .../communities/management/Description.tsx | 4 +- .../pages/communities/management/Members.tsx | 239 +++++++++++------- .../management/description/DocumentList.tsx | 9 +- .../management/member/AddMembersDialog.tsx | 147 +++-------- .../management/member/ManageGridsDialog.tsx | 2 + .../management/member/SearchUsers.tsx | 10 +- .../emailplanners/RecipientsManager.tsx | 124 --------- assets/i18n/Breadcrumb.tsx | 3 + assets/i18n/Common.tsx | 6 + assets/i18n/i18n.ts | 3 +- assets/i18n/languages/en.tsx | 2 + assets/i18n/languages/fr.tsx | 2 + assets/modules/espaceco/RQKeys.ts | 1 + assets/router/RouterRenderer.tsx | 3 + assets/router/router.ts | 7 + .../pages/espaceco/member_invitation.scss | 4 + .../EspaceCo/CommunityController.php | 51 +++- src/Dto/Espaceco/AddMembersDTO.php | 24 ++ .../EspaceCoApi/CommunityApiService.php | 39 ++- .../espaceco/member_invitation.html.twig | 7 + translations/cartesgouvfr.fr.yml | 2 + translations/validators/validators.fr.yml | 4 + 26 files changed, 610 insertions(+), 362 deletions(-) create mode 100644 assets/espaceco/pages/communities/MemberInvitation.tsx delete mode 100644 assets/espaceco/pages/communities/management/reports/emailplanners/RecipientsManager.tsx create mode 100644 assets/sass/pages/espaceco/member_invitation.scss create mode 100644 src/Dto/Espaceco/AddMembersDTO.php create mode 100644 templates/Mailer/espaceco/member_invitation.html.twig diff --git a/assets/@types/app_espaceco.ts b/assets/@types/app_espaceco.ts index 944a6fdc..48bfeba9 100644 --- a/assets/@types/app_espaceco.ts +++ b/assets/@types/app_espaceco.ts @@ -56,7 +56,7 @@ export type UserType = { surname: string | null; }; -export type Role = "pending" | "member" | "admin"; +export type Role = "pending" | "member" | "admin" | "invited"; export type CommunityMember = UserType & { grids: GridDTO[]; role: Role; @@ -64,6 +64,33 @@ export type CommunityMember = UserType & { date: string; }; +export type CommunityMemberDetailed = { + user_id: number; + community_name: string; + community_id: number; + grids: GridDTO[]; + role: Role; + active: boolean; + date: string; +}; + +export type Profile = { + community_id: number; + themes: ThemeDTO[]; +}; + +export type UserMe = { + id: number; + email: string; + username: string; + surname: string | null; + description: string | null; + administrator: boolean; + profile: Profile; + shared_themes: SharedThemesDTO[]; + communities_member: CommunityMemberDetailed[]; +}; + /* FORMULAIRES */ export type ReportFormType = { attributes: ThemeDTO[]; diff --git a/assets/espaceco/api/community.ts b/assets/espaceco/api/community.ts index 57eccd86..8a5d394d 100644 --- a/assets/espaceco/api/community.ts +++ b/assets/espaceco/api/community.ts @@ -58,6 +58,21 @@ const getCommunityMembershipRequests = (communityId: number, signal: AbortSignal }); }; +const addMembers = (communityId: number, members: (number | string)[]) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_add_members", { communityId }); + return jsonFetch( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + { members: members } + ); +}; + const updateMemberRole = (communityId: number, userId: number, role: Role) => { const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_update_member_role", { communityId, userId }); return jsonFetch( @@ -118,6 +133,7 @@ const community = { getCommunity, getCommunityMembers, getCommunityMembershipRequests, + addMembers, searchByName, getAsMember, updateMemberRole, diff --git a/assets/espaceco/api/users.ts b/assets/espaceco/api/users.ts index 5c66a576..569b65eb 100644 --- a/assets/espaceco/api/users.ts +++ b/assets/espaceco/api/users.ts @@ -1,7 +1,16 @@ +import { UserMe } from "../../@types/app_espaceco"; import { UserDTO, UserSharedThemesDTO } from "../../@types/espaceco"; import { jsonFetch } from "../../modules/jsonFetch"; import SymfonyRouting from "../../modules/Routing"; +const getMe = (signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_user_me"); + return jsonFetch(url, { + method: "GET", + signal: signal, + }); +}; + const getSharedThemes = () => { const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_user_shared_themes"); return jsonFetch(url); @@ -15,6 +24,6 @@ const search = (search: string, signal: AbortSignal) => { }); }; -const user = { search, getSharedThemes }; +const user = { getMe, search, getSharedThemes }; export default user; diff --git a/assets/espaceco/pages/communities/MemberInvitation.tsx b/assets/espaceco/pages/communities/MemberInvitation.tsx new file mode 100644 index 00000000..808247f1 --- /dev/null +++ b/assets/espaceco/pages/communities/MemberInvitation.tsx @@ -0,0 +1,223 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import ButtonsGroup from "@codegouvfr/react-dsfr/ButtonsGroup"; +import CallOut from "@codegouvfr/react-dsfr/CallOut"; +import { cx } from "@codegouvfr/react-dsfr/tools/cx"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { FC, useMemo } from "react"; +import { CommunityMember, Role, UserMe } from "../../../@types/app_espaceco"; +import { CommunityResponseDTO } from "../../../@types/espaceco"; +import AppLayout from "../../../components/Layout/AppLayout"; +import LoadingText from "../../../components/Utils/LoadingText"; +import Wait from "../../../components/Utils/Wait"; +import { datastoreNavItems } from "../../../config/datastoreNavItems"; +import { declareComponentKeys, Translations, useTranslation } from "../../../i18n/i18n"; +import RQKeys from "../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../modules/jsonFetch"; +import { routes } from "../../../router/router"; +import api from "../../api"; + +import "../../../../assets/sass/pages/espaceco/member_invitation.scss"; + +type MemberInvitationProps = { + communityId: number; +}; + +const MemberInvitation: FC = ({ communityId }) => { + const { t } = useTranslation("MemberInvitation"); + const { t: tBreadcrumb } = useTranslation("Breadcrumb"); + + const navItems = useMemo(() => datastoreNavItems(), []); + + const query = useQuery({ + queryKey: RQKeys.community(communityId), + queryFn: () => api.community.getCommunity(communityId), + staleTime: 3600000, + }); + + const meQuery = useQuery({ + queryKey: RQKeys.getMe(), + queryFn: ({ signal }) => api.user.getMe(signal), + staleTime: 3600000, + }); + + const myRole = useMemo(() => { + let role: Role | undefined; + if (meQuery.data) { + const user_id = meQuery.data.id; + const invitations = meQuery.data.communities_member.filter((m) => m.community_id === communityId && m.user_id === user_id); + if (invitations.length === 1) { + role = invitations[0].role; + } + } + return role; + }, [meQuery.data, communityId]); + + /* Invitation : role : "invited" => "member" */ + const updateRoleMutation = useMutation({ + mutationFn: () => { + if (meQuery.data?.id) { + return api.community.updateMemberRole(communityId, meQuery.data.id, "member"); + } + return Promise.resolve(undefined); + }, + }); + + /* Suppression du membre, Mais comme il a créé son compte, il reste inscrit sur cartes.gouv. */ + const removeMemberMutation = useMutation<{ user_id: number } | undefined, CartesApiException>({ + mutationFn: () => { + if (meQuery.data?.id) { + return api.community.removeMember(communityId, meQuery.data.id); + } + return Promise.resolve(undefined); + }, + onSuccess: () => routes.espaceco_community_list().push(), + }); + + const community = useMemo(() => query.data, [query.data]); + + return ( + +

{t("document_title")}

+ {query.isLoading && } + {meQuery.isLoading && } + + {query.isError && } + {meQuery.isError && } + + {updateRoleMutation.isError && } + {updateRoleMutation.isPending && ( + +
+ +
+
+ )} + + {removeMemberMutation.isError && } + {removeMemberMutation.isPending && ( + +
+ +
+
+ )} + + {community && myRole === "invited" ? ( +
+ + {t("logo")} + {t("community_name", { name: community.name })} +
+ } + > +
+ {t("community_description", { description: community.description })} + removeMemberMutation.mutate(), + }, + { + children: t("accept"), + priority: "primary", + onClick: () => updateRoleMutation.mutate(), + }, + ]} + inlineLayoutWhen="always" + alignment="left" + className={fr.cx("fr-mt-2w")} + /> +
+ + + ) : myRole !== undefined ? ( +

{t("already_member")}

+ ) : ( +

{t("no_invitation")}

+ )} +
+ ); +}; + +export default MemberInvitation; + +export const { i18n } = declareComponentKeys< + | "document_title" + | "community_loading" + | "community_loading_failed" + | "userme_loading" + | "userme_loading_failed" + | "logo" + | { K: "community_name"; P: { name: string }; R: JSX.Element } + | { K: "community_description"; P: { description: string | null }; R: JSX.Element } + | { K: "invitation"; R: JSX.Element } + | "already_member" + | "no_invitation" + | "accept" + | "reject" + | "inviting" + | "rejecting" +>()("MemberInvitation"); + +export const MemberInvitationFrTranslations: Translations<"fr">["MemberInvitation"] = { + document_title: "Invitation", + community_loading: "Chargement du guichet en cours ...", + community_loading_failed: "La récupération des informations du guichet a échoué.", + userme_loading: "Chargement de vos données utilisateur en cours ...", + userme_loading_failed: "La récupération de vous données d'utilisateur a échoué.", + logo: "Logo du guichet", + community_name: ({ name }) => ( +
+ Guichet {name} +
+ ), + community_description: ({ description }) => { + return description ?

:

Aucune description

; + }, + invitation:

Vous avez reçu une invitation à rejoindre le guichet :

, + already_member: "Vous êtes déjà membre de ce guichet", + no_invitation: "Vous n'avez pas reçu d'invitation de ce guichet", + accept: "Accepter et rejoindre le guichet", + reject: "Refuser l'invitation", + inviting: "Invitation en cours ...", + rejecting: "Refus en cours ...", +}; + +export const MemberInvitationEnTranslations: Translations<"en">["MemberInvitation"] = { + document_title: "Invitation", + community_loading: "Community loading ...", + community_loading_failed: undefined, + userme_loading: undefined, + userme_loading_failed: undefined, + logo: undefined, + community_name: ({ name }) => ( +

+ Community `${name}` +

+ ), + community_description: undefined, + invitation: undefined, + already_member: undefined, + no_invitation: undefined, + accept: undefined, + reject: undefined, + inviting: undefined, + rejecting: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/Description.tsx b/assets/espaceco/pages/communities/management/Description.tsx index 319aba60..0e928e35 100644 --- a/assets/espaceco/pages/communities/management/Description.tsx +++ b/assets/espaceco/pages/communities/management/Description.tsx @@ -48,7 +48,6 @@ const Description: FC = ({ community }) => { staleTime: 3600000, }); - // TODO DECOMMENTER const communityDocumentsQuery = useQuery({ queryKey: RQKeys.communityDocuments(community.id), queryFn: ({ signal }) => api.communityDocuments.getAll(community.id, [], signal), @@ -81,7 +80,6 @@ const Description: FC = ({ community }) => { control, register, formState: { errors }, - // setValue: setFormValue, } = useForm({ resolver: yupResolver(schema(tValid)), mode: "onChange", @@ -154,7 +152,7 @@ export default Description; export const { i18n } = declareComponentKeys<"loading_documents">()("Description"); export const DescriptionFrTranslations: Translations<"fr">["Description"] = { - loading_documents: "Recherche des tables pour la configuration des thèmes ...", + loading_documents: "Chargement des documents", }; export const DescriptionEnTranslations: Translations<"en">["Description"] = { diff --git a/assets/espaceco/pages/communities/management/Members.tsx b/assets/espaceco/pages/communities/management/Members.tsx index b232bf24..8600a57b 100644 --- a/assets/espaceco/pages/communities/management/Members.tsx +++ b/assets/espaceco/pages/communities/management/Members.tsx @@ -41,8 +41,6 @@ const Members: FC = ({ community }) => { const queryClient = useQueryClient(); const [currentPage, setCurrentPage] = useState(1); - const [errorUpdateRole, setErrorUpdateRole] = useState(undefined); - const [errorRemoveMember, setErrorRemoveMember] = useState(undefined); const [action, setAction] = useState<"remove" | "reject" | undefined>(undefined); const [currentMember, setCurrentMember] = useState(undefined); @@ -66,31 +64,46 @@ const Members: FC = ({ community }) => { mutationFn: (params) => { return api.community.updateMemberRole(params.communityId, params.userId, params.role); }, - onSuccess() { - setErrorUpdateRole(undefined); - - queryClient.refetchQueries({ queryKey: RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers) }); - queryClient.refetchQueries({ queryKey: RQKeys.communityMembershipRequests(community.id) }); - + onSuccess(response) { /* Mise à jour des données de la requête membersQuery */ - /*queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { - datas?.content.forEach((member) => { - if (member.user_id === response.user_id) { - member.role = response.role; // Mise a jour du role - } - }); + queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { + if (datas) { + datas.content.forEach((member) => { + if (member.user_id === response.user_id) { + member.role = response.role; // Mise a jour du role + } + }); + } return datas; - }); */ + }); /* Mise à jour des données de la requête membershipRequestsQuery */ - /* queryClient.setQueryData(RQKeys.communityMembershipRequests(community.id), (datas) => { + queryClient.setQueryData(RQKeys.communityMembershipRequests(community.id), (datas) => { if (datas) { - return datas.filter((member) => member.user_id !== response.user_id); + datas = datas.filter((member) => member.user_id !== response.user_id); } - }); */ + return datas; + }); }, - onError(e) { - setErrorUpdateRole(e.message); + }); + + /* Mise a jour des grids de l'utilisateur */ + const updateGridsMutation = useMutation({ + mutationFn: (params) => { + return api.community.updateMemberGrids(params.communityId, params.userId, params.grids); + }, + onSuccess(response) { + /* Mise à jour des données de la requête membersQuery */ + queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { + if (datas) { + datas.content.forEach((member) => { + if (member.user_id === response.user_id) { + member.grids = response.grids; // Mise a jour des grids + } + }); + } + return datas; + }); }, }); @@ -99,50 +112,57 @@ const Members: FC = ({ community }) => { mutationFn: ({ communityId, userId }) => { return api.community.removeMember(communityId, userId); }, - onSuccess() { - setAction(undefined); - setErrorRemoveMember(undefined); - - queryClient.refetchQueries({ queryKey: RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers) }); - queryClient.refetchQueries({ queryKey: RQKeys.communityMembershipRequests(community.id) }); - + onSuccess(response) { /* Mise à jour des données de la requête membersQuery */ - /*queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { + queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { if (datas) { datas.content = datas.content.filter((member) => member.user_id !== response.user_id); - return datas; } - });*/ + return datas; + }); /* Mise à jour des données de la requête membershipRequestsQuery */ - /*queryClient.setQueryData(RQKeys.communityMembershipRequests(community.id), (datas) => { + queryClient.setQueryData(RQKeys.communityMembershipRequests(community.id), (datas) => { if (datas) { - return datas.filter((member) => member.user_id !== response.user_id); + datas = datas.filter((member) => member.user_id !== response.user_id); } - });*/ + return datas; + }); }, - onError(e) { - setErrorRemoveMember(e.message); + onSettled: () => { + setAction(undefined); }, }); - const alert = useCallback( - (error: string) => { - return ( - -

{error}

- - - } - /> - ); + const addMembersMutation = useMutation({ + mutationFn: ({ communityId, members }) => { + return api.community.addMembers(communityId, members); }, - [t] + onSuccess() { + queryClient.refetchQueries({ queryKey: RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers) }); + }, + }); + + const alert = useCallback((title: string, error: string) => { + return ; + }, []); + + const displayWait = useCallback( + (text) => ( + +
+
+
+ +
+
+
{text}
+
+
+
+
+ ), + [] ); const headers = useMemo(() => [t("username_header"), t("name_header"), t("status_header"), t("grids_header"), ""], [t]); @@ -232,12 +252,34 @@ const Members: FC = ({ community }) => { return (
{/* les requêtes ont échoué */} - {membershipRequestsQuery.isError && alert(membershipRequestsQuery.error.message)} - {membersQuery.isError && alert(membersQuery.error.message)} - {errorUpdateRole && alert(errorUpdateRole)} - {errorRemoveMember && alert(errorRemoveMember)} + {membersQuery.isError && ( + +

{membersQuery.error.message}

+ + + } + /> + )} + + {/* LES ERREURS */} + {membershipRequestsQuery.isError && alert(t("fetch_affiliate_members_failed"), membershipRequestsQuery.error.message)} + {updateRoleMutation.isError && alert(t("update_role_failed"), updateRoleMutation.error.message)} + {updateGridsMutation.isError && alert(t("update_grids_failed"), updateGridsMutation.error.message)} + {addMembersMutation.isError && alert(t("add_members_failed"), addMembersMutation.error.message)} + {removeMemberMutation.isError && alert(t("remove_member_failed"), removeMemberMutation.error.message)} + + {/* LES ACTIONS EN COURS */} + {(membershipRequestsQuery.isLoading || membersQuery.isLoading) && } + {updateRoleMutation.isPending && displayWait(t("updating_role"))} + {updateGridsMutation.isPending && displayWait(t("updating_grids"))} + {addMembersMutation.isPending && displayWait(t("adding_members"))} + {removeMemberMutation.isPending && displayWait(t("removing_action", { action: action }))} - {(membershipRequestsQuery.isLoading || membersQuery.isLoading) && } {membershipRequestsQuery.data?.content && membershipRequestsQuery.data.content.length > 0 && ( @@ -264,34 +306,6 @@ const Members: FC = ({ community }) => { )} - {updateRoleMutation.isPending && ( - -
-
-
- -
-
-
{t("update_role")}
-
-
-
-
- )} - {removeMemberMutation.isPending && ( - -
-
-
- -
-
-
{t("remove_action", { action: action })}
-
-
-
-
- )} { @@ -300,8 +314,16 @@ const Members: FC = ({ community }) => { } }} /> - - console.log("TODOS")} /> + addMembersMutation.mutate({ communityId: community.id, members: ids })} /> + { + if (currentMember !== undefined) { + updateGridsMutation.mutate({ communityId: community.id, userId: currentMember.user_id, grids }); + } + }} + /> ); }; @@ -310,19 +332,26 @@ export default Members; // traductions export const { i18n } = declareComponentKeys< - | "fetch_failed" + | "fetch_members_failed" + | "fetch_affiliate_members_failed" + | "update_role_failed" + | "update_grids_failed" + | "add_members_failed" + | "remove_member_failed" | "back_to_list" | "loading_members" | "loading_membership_requests" | { K: "membership_requests"; P: { count: number }; R: string } + | "adding_members" | "username_header" | "name_header" | "status_header" | "grids_header" | { K: "role"; P: { role: Role }; R: string } | "date_header" - | "update_role" - | { K: "remove_action"; P: { action: "remove" | "reject" | undefined }; R: string } + | "updating_role" + | "updating_grids" + | { K: "removing_action"; P: { action: "remove" | "reject" | undefined }; R: string } | "confirm_remove" | "accept" | "accept_title" @@ -334,11 +363,17 @@ export const { i18n } = declareComponentKeys< >()("EscoCommunityMembers"); export const EscoCommunityMembersFrTranslations: Translations<"fr">["EscoCommunityMembers"] = { - fetch_failed: "La récupération des membres du guichet a échoué", + fetch_members_failed: "La récupération des membres du guichet a échoué", + fetch_affiliate_members_failed: "La récupération des demandes d'affiliation pour ce guichet a échoué", + update_role_failed: "La mise à jour du rôle de ce membre a échoué", + update_grids_failed: "La mise à jour des emprises de ce membre a échoué", + add_members_failed: "L'ajout de nouveaux membres a échoué", + remove_member_failed: "La suppression d'un membre a échoué", back_to_list: "Retour à la liste des guichets", - loading_members: "Chargement des membres du guichet", - loading_membership_requests: "Chargement des demandes d’affiliation", + loading_members: "Chargement des membres du guichet ...", + loading_membership_requests: "Chargement des demandes d’affiliation ...", membership_requests: ({ count }) => `Demandes d’affiliation (${count})`, + adding_members: "Ajout de nouveaux membres en cours ...", username_header: "Nom de l'utilisateur", name_header: "Nom, prénom", status_header: "Statut", @@ -351,11 +386,14 @@ export const EscoCommunityMembersFrTranslations: Translations<"fr">["EscoCommuni return "Membre"; case "pending": return "En attente de demande d'affiliation"; + case "invited": + return "Invité"; } }, date_header: "Date de la demande", - update_role: "Mise à jour du rôle de l'utilisateur en cours ...", - remove_action: ({ action }) => { + updating_role: "Mise à jour du rôle de l'utilisateur en cours ...", + updating_grids: "Mise à jour des emprises de l'utilisateur en cours ...", + removing_action: ({ action }) => { switch (action) { case "remove": return "Suppression de l'utilisateur en cours ..."; @@ -376,19 +414,26 @@ export const EscoCommunityMembersFrTranslations: Translations<"fr">["EscoCommuni }; export const EscoCommunityMembersEnTranslations: Translations<"en">["EscoCommunityMembers"] = { - fetch_failed: undefined, + fetch_members_failed: undefined, + fetch_affiliate_members_failed: undefined, + update_role_failed: undefined, + update_grids_failed: undefined, + add_members_failed: undefined, + remove_member_failed: undefined, back_to_list: undefined, loading_members: undefined, loading_membership_requests: undefined, membership_requests: ({ count }) => `Membership requests (${count})`, + adding_members: "Adding new members ...", username_header: "username", name_header: undefined, status_header: "Status", grids_header: undefined, role: ({ role }) => `${role}`, date_header: undefined, - update_role: undefined, - remove_action: ({ action }) => `${action}`, + updating_role: undefined, + updating_grids: undefined, + removing_action: ({ action }) => `${action}`, confirm_remove: undefined, accept: undefined, accept_title: undefined, diff --git a/assets/espaceco/pages/communities/management/description/DocumentList.tsx b/assets/espaceco/pages/communities/management/description/DocumentList.tsx index 9acef085..15ef93f4 100644 --- a/assets/espaceco/pages/communities/management/description/DocumentList.tsx +++ b/assets/espaceco/pages/communities/management/description/DocumentList.tsx @@ -191,7 +191,14 @@ const DocumentList: FC = ({ communityId, documents }) => { )} -
+ {datas.length ? ( +
+ ) : ( +
+

{t("desc.documents")}

+
{t("desc.no_documents")}
+
+ )}
- ) : ( -
{t("none")}
- )} - -
- - {newUsersData.length > 0 ? ( -
- ) : ( -
{t("none")}
- )} - , document.body @@ -174,50 +105,32 @@ const AddMembersDialog: FC = () => { export { AddMembersDialog, AddMembersDialogModal }; // traductions -export const { i18n } = declareComponentKeys< - | "invite_title" - | "invite" - | "emails" - | { K: "emails_hint"; R: JSX.Element } - | { K: "email_not_valid"; P: { value: string }; R: string } - | "gp_users_to_add" - | "users_to_add" - | "none" ->()("AddMembersDialog"); +export const { i18n } = declareComponentKeys<"invite_title" | "invite" | { K: "users_hint"; R: JSX.Element } | "min_users_error">()("AddMembersDialog"); export const AddMembersDialogFrTranslations: Translations<"fr">["AddMembersDialog"] = { invite_title: "Inviter des membres", invite: "Inviter", - emails: "Emails", - emails_hint: ( + users_hint: ( <>
    -
  • Invitez un utilisateur de la géoplateforme par son login ou son nom d’utilisateur.
  • +
  • Invitez un utilisateur de la géoplateforme par son login ou son nom d’utilisateur (autocomplétion).
  • Invitez un utilisateur qui ne fait pas partie de la géoplateforme par son adresse email. Un message lui sera envoyé pour créer un compte - (séparez les adresses email par une virgule).
  • -
  • Vous pouvez inviter plusieurs membres au groupe en une seule fois
  • +
  • Vous pouvez inviter plusieurs membres à ce guichet en une seule fois

-
Une fois les membres invités au groupe, vous pourrez ensuite en désigner certains comme gestionnaires.
+
Une fois les membres invités, vous pourrez ensuite en désigner certains comme gestionnaires.
), - email_not_valid: ({ value }) => `La chaîne ${value} n'est pas un email valide`, - gp_users_to_add: "Utilisateurs de la géoplateforme à ajouter", - users_to_add: "Utilisateurs hors géoplateforme à ajouter", - none: "Aucun", + min_users_error: "Au minimum, un utilisateur ou un email est requis", }; export const AddMembersDialogEnTranslations: Translations<"en">["AddMembersDialog"] = { invite_title: "Invite members", invite: "Invite", - emails: "Emails", - emails_hint: undefined, - email_not_valid: ({ value }) => `${value} is not valid email`, - gp_users_to_add: undefined, - users_to_add: undefined, - none: "None", + users_hint: undefined, + min_users_error: undefined, }; diff --git a/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx b/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx index fea4e773..ad4457ae 100644 --- a/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx +++ b/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx @@ -40,6 +40,7 @@ const ManageGridsDialog: FC = ({ communityGrids, userGri }); const { + reset, setValue: setFormValue, getValues: getFormValues, handleSubmit, @@ -67,6 +68,7 @@ const ManageGridsDialog: FC = ({ communityGrids, userGri const userGrids = getFormValues("user_grids") ?? []; onApply(userGrids); + reset({ user_grids: [] }); }; return ( diff --git a/assets/espaceco/pages/communities/management/member/SearchUsers.tsx b/assets/espaceco/pages/communities/management/member/SearchUsers.tsx index bda27834..457b302a 100644 --- a/assets/espaceco/pages/communities/management/member/SearchUsers.tsx +++ b/assets/espaceco/pages/communities/management/member/SearchUsers.tsx @@ -10,6 +10,7 @@ import { UserDTO } from "../../../../../@types/espaceco"; import { useTranslation } from "../../../../../i18n/i18n"; import RQKeys from "../../../../../modules/espaceco/RQKeys"; import api from "../../../../api"; +import isEmail from "validator/lib/isEmail"; import "../../../../../sass/components/autocomplete.scss"; @@ -65,8 +66,13 @@ const SearchUsers: FC = ({ label, hintText, value, state, stat return option === v; }} onInputChange={(_, v) => setSearch(v)} - onChange={(_, v) => { - onChange(v); + onChange={(_, value) => { + if (value && Array.isArray(value)) { + value = value.filter((v) => { + return isUser(v) || isEmail(v); + }); + onChange(value); + } }} /> diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/RecipientsManager.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/RecipientsManager.tsx deleted file mode 100644 index 663ff927..00000000 --- a/assets/espaceco/pages/communities/management/reports/emailplanners/RecipientsManager.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { fr } from "@codegouvfr/react-dsfr"; -import Checkbox from "@codegouvfr/react-dsfr/Checkbox"; -import { yupResolver } from "@hookform/resolvers/yup"; -import { FC, useEffect } from "react"; -import { Controller, useForm, useWatch } from "react-hook-form"; -import isEmail from "validator/lib/isEmail"; -import * as yup from "yup"; -import { BasicRecipientsArray } from "../../../../../../@types/app_espaceco"; -import { BasicRecipients } from "../../../../../../@types/espaceco"; -import InputCollection from "../../../../../../components/Input/InputCollection"; -import { useTranslation } from "../../../../../../i18n/i18n"; - -type RecipientFormType = { - basicRecipients?: string[]; - extraRecipients?: string[]; -}; - -type RecipientsManagerProps = { - value: string[]; - state?: "default" | "error" | "success"; - stateRelatedMessage?: string; - onChange: (values: string[]) => void; -}; - -const defaultValues = (recipients) => { - return { - basicRecipients: recipients.filter((recipient) => !isEmail(recipient)), - extraRecipients: recipients.filter((recipient) => isEmail(recipient)), - }; -}; - -const RecipientsManager: FC = ({ value, state, stateRelatedMessage, onChange }) => { - const { t } = useTranslation("AddOrEditEmailPlanner"); - - const schema = yup.object({ - basicRecipients: yup.array().of(yup.string().oneOf(BasicRecipientsArray).required()), - extraRecipients: yup - .array() - .of(yup.string().required()) - .test({ - name: "check-email", - test(value, ctx) { - if (value) { - for (const v of value) { - if (!isEmail(v)) { - return ctx.createError({ message: t("validation.error.email_not_valid", { value: v }) }); - } - } - } - return true; - }, - }), - }); - - const { - control, - register, - formState: { errors }, - } = useForm({ - mode: "onChange", - resolver: yupResolver(schema), - values: defaultValues(value), - }); - - const basicRecipients = useWatch({ - control: control, - name: "basicRecipients", - }); - const extraRecipients = useWatch({ - control: control, - name: "extraRecipients", - }); - - useEffect(() => { - const recipients = [...(basicRecipients ?? []), ...(extraRecipients ?? [])]; - onChange(recipients); - }, [basicRecipients, extraRecipients, onChange]); - - return ( -
-
{t("dialog.recipients")}
- ({ - label: t("recipient", { name: r }), - nativeInputProps: { - ...register("basicRecipients"), - value: r, - }, - }))} - /> - ( - - )} - /> - {state !== "default" && ( -

{ - switch (state) { - case "error": - return "fr-error-text"; - case "success": - return "fr-valid-text"; - } - })() - )} - > - {stateRelatedMessage} -

- )} -
- ); -}; - -export default RecipientsManager; diff --git a/assets/i18n/Breadcrumb.tsx b/assets/i18n/Breadcrumb.tsx index cb2d3731..e1419075 100644 --- a/assets/i18n/Breadcrumb.tsx +++ b/assets/i18n/Breadcrumb.tsx @@ -41,6 +41,7 @@ export const { i18n } = declareComponentKeys< | "datastore_pyramid_vector_tms_service_edit" | "datastore_service_view" | "espaceco_community_list" + | "espaceco_member_invitation" | { K: "espaceco_manage_community"; P: { communityName?: string }; R: string } >()("Breadcrumb"); @@ -86,6 +87,7 @@ export const BreadcrumbFrTranslations: Translations<"fr">["Breadcrumb"] = { datastore_service_view: "Prévisualisation d'un service", espaceco_community_list: "Espace collaboratif", espaceco_manage_community: ({ communityName }) => `Gérer le guichet ${communityName ?? ""}`, + espaceco_member_invitation: "Invitation", }; export const BreadcrumbEnTranslations: Translations<"en">["Breadcrumb"] = { @@ -130,4 +132,5 @@ export const BreadcrumbEnTranslations: Translations<"en">["Breadcrumb"] = { datastore_service_view: "Preview a service", espaceco_community_list: "Collaborative space", espaceco_manage_community: ({ communityName }) => `Manage community ${communityName ?? ""}`, + espaceco_member_invitation: "Invitation", }; diff --git a/assets/i18n/Common.tsx b/assets/i18n/Common.tsx index 1082a5cf..6a7a6639 100644 --- a/assets/i18n/Common.tsx +++ b/assets/i18n/Common.tsx @@ -22,6 +22,8 @@ export const { i18n } = declareComponentKeys< | "see" | "yes" | "no" + | "accept" + | "reject" | "publish" | "unpublish" | "published" @@ -60,6 +62,8 @@ export const commonFrTranslations: Translations<"fr">["Common"] = { see: "Consulter", yes: "Oui", no: "Non", + accept: "Accepter", + reject: "Refuser", publish: "Publier", unpublish: "Dépublier", published: "Publié", @@ -98,6 +102,8 @@ export const commonEnTranslations: Translations<"en">["Common"] = { see: "Check", yes: "Yes", no: "No", + accept: "Accept", + reject: "Reject", publish: "Publish", unpublish: "Unpublish", published: "Published", diff --git a/assets/i18n/i18n.ts b/assets/i18n/i18n.ts index 83fa2db2..0b024e94 100644 --- a/assets/i18n/i18n.ts +++ b/assets/i18n/i18n.ts @@ -65,7 +65,8 @@ export type ComponentKey = | typeof import("../espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords").i18n | typeof import("../espaceco/pages/communities/management/Members").i18n | typeof import("../espaceco/pages/communities/management/member/AddMembersDialog").i18n - | typeof import("../espaceco/pages/communities/management/member/ManageGridsDialog").i18n; + | typeof import("../espaceco/pages/communities/management/member/ManageGridsDialog").i18n + | typeof import("../espaceco/pages/communities/MemberInvitation").i18n; export type Translations = GenericTranslations; export type LocalizedString = Parameters[0]; diff --git a/assets/i18n/languages/en.tsx b/assets/i18n/languages/en.tsx index 485cc3ea..36fa7c61 100644 --- a/assets/i18n/languages/en.tsx +++ b/assets/i18n/languages/en.tsx @@ -27,6 +27,7 @@ import { PermissionsEnTranslations } from "../../entrepot/pages/users/permission import { CommunityListEnTranslations } from "../../espaceco/pages/communities/CommunityListTr"; import { ManageCommunityEnTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; import { ManageCommunityValidationsEnTranslations } from "../../espaceco/pages/communities/management/validationTr"; +import { MemberInvitationEnTranslations } from "../../espaceco/pages/communities/MemberInvitation"; import { SearchEnTranslations } from "../../espaceco/pages/communities/management/SearchTr"; import { TMSStyleFilesManagerEnTranslations } from "../../modules/Style/TMSStyleFilesManager"; import { contactEnTranslations } from "../../pages/assistance/contact/Contact"; @@ -90,6 +91,7 @@ export const translations: Translations<"en"> = { CommunityList: CommunityListEnTranslations, ManageCommunity: ManageCommunityEnTranslations, ManageCommunityValidations: ManageCommunityValidationsEnTranslations, + MemberInvitation: MemberInvitationEnTranslations, Theme: ThemeEnTranslations, Description: DescriptionEnTranslations, Reports: ReportsEnTranslations, diff --git a/assets/i18n/languages/fr.tsx b/assets/i18n/languages/fr.tsx index ec642df0..d514d3d4 100644 --- a/assets/i18n/languages/fr.tsx +++ b/assets/i18n/languages/fr.tsx @@ -27,6 +27,7 @@ import { PermissionsFrTranslations } from "../../entrepot/pages/users/permission import { CommunityListFrTranslations } from "../../espaceco/pages/communities/CommunityListTr"; import { ManageCommunityFrTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; import { ManageCommunityValidationsFrTranslations } from "../../espaceco/pages/communities/management/validationTr"; +import { MemberInvitationFrTranslations } from "../../espaceco/pages/communities/MemberInvitation"; import { SearchFrTranslations } from "../../espaceco/pages/communities/management/SearchTr"; import { TMSStyleFilesManagerFrTranslations } from "../../modules/Style/TMSStyleFilesManager"; import { contactFrTranslations } from "../../pages/assistance/contact/Contact"; @@ -90,6 +91,7 @@ export const translations: Translations<"fr"> = { CommunityList: CommunityListFrTranslations, ManageCommunity: ManageCommunityFrTranslations, ManageCommunityValidations: ManageCommunityValidationsFrTranslations, + MemberInvitation: MemberInvitationFrTranslations, Reports: ReportsFrTranslations, Description: DescriptionFrTranslations, EmailPlanners: EmailPlannersFrTranslations, diff --git a/assets/modules/espaceco/RQKeys.ts b/assets/modules/espaceco/RQKeys.ts index 5613ccce..b25a4889 100644 --- a/assets/modules/espaceco/RQKeys.ts +++ b/assets/modules/espaceco/RQKeys.ts @@ -25,6 +25,7 @@ const RQKeys = { userSharedThemes: (): string[] => ["user", "shared_themes"], searchAddress: (search: string): string[] => ["searchAddress", search], searchGrids: (text: string): string[] => ["searchGrids", text], + getMe: (): string[] => ["espaceco", "users", "me"], searchUsers: (text: string): string[] => ["searchUsers", text], tables: (communityId: number): string[] => ["feature_types", communityId.toString()], emailPlanners: (communityId: number): string[] => ["emailplanners", communityId.toString()], diff --git a/assets/router/RouterRenderer.tsx b/assets/router/RouterRenderer.tsx index cd6a3e52..4cb753a9 100644 --- a/assets/router/RouterRenderer.tsx +++ b/assets/router/RouterRenderer.tsx @@ -8,6 +8,7 @@ import RedirectToLogin from "../pages/RedirectToLogin"; import PageNotFound from "../pages/error/PageNotFound"; import { useAuthStore } from "../stores/AuthStore"; import { knownRoutes, publicRoutes, routes, useRoute } from "./router"; +import MemberInvitation from "../espaceco/pages/communities/MemberInvitation"; const About = lazy(() => import("../pages/About")); const Documentation = lazy(() => import("../pages/Documentation")); @@ -187,6 +188,8 @@ const RouterRenderer: FC = () => { return ; case "espaceco_manage_community": return ; + case "espaceco_member_invitation": + return ; default: return ; } diff --git a/assets/router/router.ts b/assets/router/router.ts index 73e0358b..21425315 100644 --- a/assets/router/router.ts +++ b/assets/router/router.ts @@ -237,6 +237,13 @@ const routeDefs = { }, (p) => `${appRoot}/espace-collaboratif/${p.communityId}/gerer-le-guichet` ), + + espaceco_member_invitation: defineRoute( + { + communityId: param.path.number, + }, + (p) => `${appRoot}/espace-collaboratif/${p.communityId}/invitation` + ), }; export const { RouteProvider, useRoute, routes, session } = createRouter(routeDefs); diff --git a/assets/sass/pages/espaceco/member_invitation.scss b/assets/sass/pages/espaceco/member_invitation.scss new file mode 100644 index 00000000..1d6062ea --- /dev/null +++ b/assets/sass/pages/espaceco/member_invitation.scss @@ -0,0 +1,4 @@ +.frx-invitation-img { + max-width: 48px; + max-height: 48px; +} diff --git a/src/Controller/EspaceCo/CommunityController.php b/src/Controller/EspaceCo/CommunityController.php index f82b7f18..bd847cd8 100644 --- a/src/Controller/EspaceCo/CommunityController.php +++ b/src/Controller/EspaceCo/CommunityController.php @@ -3,17 +3,21 @@ namespace App\Controller\EspaceCo; use App\Controller\ApiControllerInterface; +use App\Dto\Espaceco\Members\AddMembersDTO; use App\Exception\ApiException; use App\Exception\CartesApiException; use App\Services\EspaceCoApi\CommunityApiService; use App\Services\EspaceCoApi\UserApiService; +use App\Services\MailerService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Uid\Uuid; #[Route( @@ -31,8 +35,9 @@ class CommunityController extends AbstractController implements ApiControllerInt public function __construct( ParameterBagInterface $parameters, private Filesystem $fs, + private MailerService $mailerService, private CommunityApiService $communityApiService, - private UserApiService $userApiService + private UserApiService $userApiService, ) { $this->varDataPath = $parameters->get('upload_path'); } @@ -69,7 +74,7 @@ public function getCommunitiesName(): JsonResponse public function getMeMember( #[MapQueryParameter] bool $pending, #[MapQueryParameter] ?int $page = 1, - #[MapQueryParameter] ?int $limit = 10 + #[MapQueryParameter] ?int $limit = 10, ): JsonResponse { try { $me = $this->userApiService->getMe(); @@ -152,7 +157,7 @@ public function getMembers( int $communityId, #[MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/^admin|member|pending$/'])] array $roles = [], #[MapQueryParameter] ?int $page = 1, - #[MapQueryParameter(options: ['min_range' => 1, 'max_range' => 50])] ?int $limit = 10 + #[MapQueryParameter(options: ['min_range' => 1, 'max_range' => 50])] ?int $limit = 10, ): JsonResponse { try { $response = $this->communityApiService->getCommunityMembers($communityId, $roles, $page, $limit); @@ -163,11 +168,35 @@ public function getMembers( } } + #[Route('/{communityId}/members', name: 'add_members', methods: ['POST'])] + public function addMembers( + int $communityId, + #[MapRequestPayload] AddMembersDTO $dto): JsonResponse + { + try { + $community = $this->communityApiService->getCommunity($communityId); + + $members = []; + foreach ($dto->members as $userId) { + $email = filter_var($userId, FILTER_VALIDATE_EMAIL); + $member = $this->communityApiService->addMember($communityId, $userId); + if ($email && 'invited' === $member['role']) { + $this->_sendEmail($email, $community); + } else { + $members[] = $member; + } + } + + return new JsonResponse($members); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + #[Route('/{communityId}/member/{userId}/update_role', name: 'update_member_role', methods: ['PATCH'])] public function updateMemberRole(int $communityId, int $userId, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); - $member = $this->communityApiService->updateMember($communityId, $userId, 'role', $data['role']); return new JsonResponse($member); @@ -177,7 +206,6 @@ public function updateMemberRole(int $communityId, int $userId, Request $request public function updateMemberGrids(int $communityId, int $userId, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); - $member = $this->communityApiService->updateMember($communityId, $userId, 'grids', $data['grids']); return new JsonResponse($member); @@ -276,4 +304,17 @@ private function _sortMembersByName(array &$members): void return ($upM1 < $upM2) ? -1 : 1; }); } + + /** + * @param array $community + */ + private function _sendEmail(string $email, array $community): void + { + $communityId = $community['id']; + $communityName = $community['name']; + $subject = "[cartes.gouv.fr] Invitation au guichet $communityName"; + + $url = $this->generateUrl('cartesgouvfr_app', [], UrlGeneratorInterface::ABSOLUTE_URL)."espace-collaboratif/$communityId/invitation"; + $this->mailerService->sendMail($email, $subject, 'Mailer/espaceco/member_invitation.html.twig', ['community' => $community, 'link' => $url]); + } } diff --git a/src/Dto/Espaceco/AddMembersDTO.php b/src/Dto/Espaceco/AddMembersDTO.php new file mode 100644 index 00000000..a4ad5e56 --- /dev/null +++ b/src/Dto/Espaceco/AddMembersDTO.php @@ -0,0 +1,24 @@ + $members + */ + public function __construct( + #[Assert\All([ + new Assert\AtLeastOneOf([ + new Assert\Positive(), + new Assert\Email([ + 'message' => 'espaceco.add_members.email_error', + ]), + ]), + ])] + public readonly array $members = [], + ) { + } +} diff --git a/src/Services/EspaceCoApi/CommunityApiService.php b/src/Services/EspaceCoApi/CommunityApiService.php index 152e2b0b..8181a515 100644 --- a/src/Services/EspaceCoApi/CommunityApiService.php +++ b/src/Services/EspaceCoApi/CommunityApiService.php @@ -16,7 +16,7 @@ public function __construct(HttpClientInterface $httpClient, RequestStack $requestStack, LoggerInterface $logger, private UserApiService $userApiService, - private GridApiService $gridApiService + private GridApiService $gridApiService, ) { parent::__construct($httpClient, $parameters, $filesystem, $requestStack, $logger); } @@ -79,14 +79,7 @@ public function getCommunityMembers(int $communityId, array $roles, int $page, i $member = array_merge($member, $user); // Ajout des grids - $grids = []; - foreach ($member['grids'] as $name) { - $grid = $this->gridApiService->getGrid($name); - if (!$grid['deleted']) { - $grids[] = $grid; - } - } - $member['grids'] = $grids; + $member['grids'] = $this->_transformGrids($member['grids']); } usort($members, function ($mb1, $mb2) { @@ -105,9 +98,19 @@ public function getCommunityMembers(int $communityId, array $roles, int $page, i ]; } + public function addMember(int $communityId, int $userId): array + { + return $this->request('POST', "communities/$communityId/members/$userId", ['user_id' => $userId]); + } + public function updateMember(int $communityId, int $userId, string $field, mixed $value): array { - return $this->request('PATCH', "communities/$communityId/members/$userId", [$field => $value]); + $member = $this->request('PATCH', "communities/$communityId/members/$userId", [$field => $value]); + if ('grids' === $field) { // On recupere les grids, pas seulement leur nom + $member['grids'] = $this->_transformGrids($member['grids']); + } + + return $member; } public function removeMember(int $communityId, int $userId): array @@ -124,4 +127,20 @@ public function removeLogo(int $communityId): array { return $this->request('DELETE', "communities/$communityId/logo"); } + + /** + * @param array $grids + */ + private function _transformGrids(array $grids): array + { + $result = []; + foreach ($grids as $name) { + $grid = $this->gridApiService->getGrid($name); + if (!$grid['deleted']) { + $result[] = $grid; + } + } + + return $result; + } } diff --git a/templates/Mailer/espaceco/member_invitation.html.twig b/templates/Mailer/espaceco/member_invitation.html.twig new file mode 100644 index 00000000..d42ac430 --- /dev/null +++ b/templates/Mailer/espaceco/member_invitation.html.twig @@ -0,0 +1,7 @@ +{% extends 'Mailer/base.html.twig' %} +{% trans_default_domain 'cartesgouvfr' %} + +{% block body %} +

{{ 'mailer.espaceco.member_invitation'|trans({'%name%': community.name, '%link%': link})|raw }}

+ +{% endblock %} diff --git a/translations/cartesgouvfr.fr.yml b/translations/cartesgouvfr.fr.yml index 94a593e1..ab8949b4 100644 --- a/translations/cartesgouvfr.fr.yml +++ b/translations/cartesgouvfr.fr.yml @@ -28,6 +28,8 @@ mailer: first_name: "Prénom" user_id: "Identifiant" date: "Date de la demande" + espaceco: + member_invitation: "Vous avez été invité à rejoindre la communauté %name%.
Vous pouvez accepter ou rejeter cette invitation en cliquant sur ce lien %link%." # pages: home: diff --git a/translations/validators/validators.fr.yml b/translations/validators/validators.fr.yml index 3f6e4a98..d7fbd6ab 100644 --- a/translations/validators/validators.fr.yml +++ b/translations/validators/validators.fr.yml @@ -103,3 +103,7 @@ common: attribution_url_error: "L'URL de l'attribution n'est pas valide" resolution_error: "La résolution n'est pas valide" share_with_error: "La restriction n'est pas valide" + +espaceco: + add_members: + email_error: "l'email n'est pas valide"