From 569aff05a15ff13f3698adbf94543b1e889949e1 Mon Sep 17 00:00:00 2001 From: elvoisin <95469923+elvoisin@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:06:18 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20add=20show=20invitations=20m?= =?UTF-8?q?ails=20domains=20access=20(#1040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨(front) add show invitations mails domains access add show invitations to mails domains access * ✨(front) delete invitations mails domains access add delete button for delete invitations to mails domains access --- CHANGELOG.md | 3 + .../access-management/api/index.ts | 2 + .../api/useDeleteMailDomainInvitation.tsx | 89 +++++++++++++ .../api/useInvitationMailDomainAccesses.tsx | 42 ++++++ .../components/AccessesList.tsx | 91 ++++++++++++- .../components/InvitationAction.tsx | 126 ++++++++++++++++++ .../domains/components/MailDomainView.tsx | 6 +- 7 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainInvitation.tsx create mode 100644 src/frontend/apps/desk/src/features/mail-domains/access-management/api/useInvitationMailDomainAccesses.tsx create mode 100644 src/frontend/apps/desk/src/features/mail-domains/access-management/components/InvitationAction.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7119ded..4a2a0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ and this project adheres to ### Added +- ✨(front) delete invitations mails domains access +- ✨(front) add show invitations mails domains access #1040 - ✨(invitations) can delete domain invitations ## Changed - 🏗️(core) migrate from pip to uv +- ✨(front) add show invitations mails domains access #1040 ## [1.22.2] - 2026-01-26 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 9090bd6..edca4aa 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,5 +1,7 @@ export * from './useMailDomainAccesses'; +export * from './useInvitationMailDomainAccesses'; export * from './useUpdateMailDomainAccess'; export * from './useCreateMailDomainAccess'; export * from './useDeleteMailDomainAccess'; +export * from './useDeleteMailDomainInvitation'; export * from './useCreateInvitation'; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainInvitation.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainInvitation.tsx new file mode 100644 index 0000000..5085a55 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useDeleteMailDomainInvitation.tsx @@ -0,0 +1,89 @@ +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_INVITATION_DOMAIN_ACCESSES } from './useInvitationMailDomainAccesses'; + +interface DeleteMailDomainInvitationProps { + slug: string; + invitationId: string; +} + +export const deleteMailDomainInvitation = async ({ + slug, + invitationId, +}: DeleteMailDomainInvitationProps): Promise => { + const response = await fetchAPI( + `mail-domains/${slug}/invitations/${invitationId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete the invitation', + await errorCauses(response), + ); + } +}; + +type UseDeleteMailDomainInvitationOptions = UseMutationOptions< + void, + APIError, + DeleteMailDomainInvitationProps +>; + +export const useDeleteMailDomainInvitation = ( + options?: UseDeleteMailDomainInvitationOptions, +) => { + const queryClient = useQueryClient(); + const { + onSuccess: optionsOnSuccess, + onError: optionsOnError, + ...restOptions + } = options || {}; + return useMutation({ + mutationFn: deleteMailDomainInvitation, + ...restOptions, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_INVITATION_DOMAIN_ACCESSES], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_MAIL_DOMAIN], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_MAIL_DOMAIN], + }); + if (optionsOnSuccess) { + ( + optionsOnSuccess as unknown as ( + data: void, + variables: DeleteMailDomainInvitationProps, + context: unknown, + ) => void + )(data, variables, context); + } + }, + onError: (error, variables, context) => { + if (optionsOnError) { + ( + optionsOnError as unknown as ( + error: APIError, + variables: DeleteMailDomainInvitationProps, + context: unknown, + ) => void + )(error, variables, context); + } + }, + }); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useInvitationMailDomainAccesses.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useInvitationMailDomainAccesses.tsx new file mode 100644 index 0000000..9901924 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/api/useInvitationMailDomainAccesses.tsx @@ -0,0 +1,42 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; + +import { Access } from '../types'; + +export type InvitationMailDomainAccessesAPIParams = { + slug: string; +}; + +type AccessesResponse = APIList; + +export const getInvitationMailDomainAccesses = async ({ + slug, +}: InvitationMailDomainAccessesAPIParams): Promise => { + const url = `mail-domains/${slug}/invitations/`; + + const response = await fetchAPI(url); + + if (!response.ok) { + throw new APIError( + 'Failed to get the invitations', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_INVITATION_DOMAIN_ACCESSES = + 'invitation-mail-domains-accesses'; + +export function useInvitationMailDomainAccesses( + params: InvitationMailDomainAccessesAPIParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_INVITATION_DOMAIN_ACCESSES, params], + queryFn: () => getInvitationMailDomainAccesses(params), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesList.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesList.tsx index e75deb9..f10e480 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesList.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/AccessesList.tsx @@ -4,13 +4,15 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, SeparatedSection, Text, TextErrors } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; import { MailDomain, Role } from '../../domains'; -import { useMailDomainAccesses } from '../api'; +import { useInvitationMailDomainAccesses, useMailDomainAccesses } from '../api'; import { PAGE_SIZE } from '../conf'; import { Access } from '../types'; import { AccessAction } from './AccessAction'; +import { InvitationAction } from './InvitationAction'; interface AccessesListProps { mailDomain: MailDomain; @@ -22,6 +24,13 @@ type SortModelItem = { sort: 'asc' | 'desc' | null; }; +type MailDomainInvitation = { + id: string; + email: string; + role: Role; + can_set_role_to?: Role[]; +}; + const defaultOrderingMapping: Record = { 'user.name': 'user__name', 'user.email': 'user__email', @@ -53,12 +62,14 @@ export const AccessesList = ({ mailDomain, currentRole, }: AccessesListProps) => { + const { colorsTokens } = useCunninghamTheme(); const { t } = useTranslation(); const pagination = usePagination({ pageSize: PAGE_SIZE, }); const sortModel: SortModel = []; const [accesses, setAccesses] = useState([]); + const [invitationsAccesses, setInvitationAccesses] = useState([]); const { page, pageSize, setPagesCount } = pagination; const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined; @@ -69,8 +80,16 @@ export const AccessesList = ({ ordering, }); + const { + data: invitationsData, + isLoading: invitationsIsLoading, + error: invitationsError, + } = useInvitationMailDomainAccesses({ + slug: mailDomain.slug, + }); + useEffect(() => { - if (isLoading) { + if (isLoading && invitationsIsLoading) { return; } @@ -89,8 +108,29 @@ export const AccessesList = ({ }, })) || []; + const invitationsAccesses = + (invitationsData?.results as unknown as MailDomainInvitation[])?.map( + (invitation: MailDomainInvitation): Access => ({ + id: invitation.id as `${string}-${string}-${string}-${string}-${string}`, + role: invitation.role, + can_set_role_to: invitation.can_set_role_to || [], + user: { + id: invitation.id, + email: invitation.email || '', + name: invitation.email || '', + }, + }), + ) || []; + setAccesses(accesses); - }, [data?.results, t, isLoading]); + setInvitationAccesses(invitationsAccesses); + }, [ + data?.results, + invitationsData?.results, + t, + isLoading, + invitationsIsLoading, + ]); useEffect(() => { setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0); @@ -105,8 +145,51 @@ export const AccessesList = ({ return ( <> + {invitationsAccesses && invitationsAccesses.length > 0 && ( + + + {t('Invitations')} + + {invitationsError && } + {invitationsAccesses.map((access) => ( + + + + + {t('on pending')} + + + } + /> + + {localizedRoles[access.role]} + + + + ))} + + )} diff --git a/src/frontend/apps/desk/src/features/mail-domains/access-management/components/InvitationAction.tsx b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/InvitationAction.tsx new file mode 100644 index 0000000..19fb609 --- /dev/null +++ b/src/frontend/apps/desk/src/features/mail-domains/access-management/components/InvitationAction.tsx @@ -0,0 +1,126 @@ +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { IconOptions, Text } from '@/components'; + +import { MailDomain, Role } from '../../domains/types'; +import { useDeleteMailDomainInvitation } from '../api'; +import { Access } from '../types'; + +interface InvitationActionProps { + access: Access; + currentRole: Role; + mailDomain: MailDomain; +} + +export const InvitationAction = ({ + access, + currentRole, + mailDomain, +}: InvitationActionProps) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const [isDropOpen, setIsDropOpen] = useState(false); + const dropdownRef = useRef(null); + + const { mutate: deleteMailDomainInvitation } = useDeleteMailDomainInvitation({ + onSuccess: () => { + toast(t('The invitation has been deleted'), VariantType.SUCCESS, { + duration: 4000, + }); + }, + }); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setIsDropOpen(false); + } + }; + if (isDropOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isDropOpen]); + + if (currentRole === Role.VIEWER || !mailDomain.abilities.delete) { + return null; + } + + return ( +
+ + + {isDropOpen && ( +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Escape' || e.key === 'Enter') { + setIsDropOpen(false); + } + }} + > + +
+ )} +
+ ); +}; diff --git a/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainView.tsx b/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainView.tsx index 9b434bd..2b07b3a 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainView.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/domains/components/MailDomainView.tsx @@ -43,7 +43,11 @@ export const MailDomainView = ({ }); const countMailboxes = mailboxesData?.count ?? 0; - const countAliases = aliasesData?.count ?? 0; + const countAliases = + aliasesData?.results.filter( + (alias, index, self) => + index === self.findIndex((a) => a.local_part === alias.local_part), + ).length ?? 0; const handleShowModal = () => { setShowModal(true);