(front) add show invitations mails domains access (#1040)

* (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
This commit is contained in:
elvoisin
2026-02-03 16:06:18 +01:00
committed by GitHub
parent b13f4db536
commit 569aff05a1
7 changed files with 354 additions and 5 deletions

View File

@@ -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

View File

@@ -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';

View File

@@ -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<void> => {
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<void, APIError, DeleteMailDomainInvitationProps>({
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);
}
},
});
};

View File

@@ -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<Access>;
export const getInvitationMailDomainAccesses = async ({
slug,
}: InvitationMailDomainAccessesAPIParams): Promise<AccessesResponse> => {
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<AccessesResponse>;
};
export const KEY_LIST_INVITATION_DOMAIN_ACCESSES =
'invitation-mail-domains-accesses';
export function useInvitationMailDomainAccesses(
params: InvitationMailDomainAccessesAPIParams,
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
) {
return useQuery<AccessesResponse, APIError, AccessesResponse>({
queryKey: [KEY_LIST_INVITATION_DOMAIN_ACCESSES, params],
queryFn: () => getInvitationMailDomainAccesses(params),
...queryConfig,
});
}

View File

@@ -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<string, string> = {
'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<Access[]>([]);
const [invitationsAccesses, setInvitationAccesses] = useState<Access[]>([]);
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,9 +145,52 @@ export const AccessesList = ({
return (
<>
<SeparatedSection />
{invitationsAccesses && invitationsAccesses.length > 0 && (
<Box
$margin={{ bottom: 'xl', top: 'md' }}
$padding={{ horizontal: 'md' }}
>
<Text $size="small" $margin={{ bottom: 'md' }} $weight="600">
{t('Invitations')}
</Text>
{invitationsError && <TextErrors causes={invitationsError.cause} />}
{invitationsAccesses.map((access) => (
<Box key={access.id} $direction="row" $align="space-between">
<QuickSearchItemTemplate
key={access.id}
left={
<Box $direction="row" className="c__share-member-item">
<UserRow
key={access.user.email}
fullName={undefined}
email={access.user.email}
showEmail
/>
<Text
$size="small"
$margin={{ left: '4px' }}
$color={colorsTokens()['greyscale-500']}
>
{t('on pending')}
</Text>
</Box>
}
/>
<Box $direction="row" $align="center">
<Text>{localizedRoles[access.role]}</Text>
<InvitationAction
mailDomain={mailDomain}
access={access}
currentRole={currentRole}
/>
</Box>
</Box>
))}
</Box>
)}
<Box
$margin={{ bottom: 'md', top: 'md' }}
$padding={{ horizontal: 'md' }}
>
<Text $size="small" $margin={{ bottom: 'md' }} $weight="600">
{t('Rights shared with ')}

View File

@@ -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<HTMLDivElement>(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 (
<div
style={{ position: 'relative', display: 'inline-block' }}
ref={dropdownRef}
>
<button
onClick={() => setIsDropOpen((prev) => !prev)}
aria-label={t('Open the invitation options modal')}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
}}
type="button"
>
<IconOptions />
</button>
{isDropOpen && (
<div
role="menu"
tabIndex={0}
style={{
position: 'absolute',
top: '100%',
right: 0,
zIndex: 1000,
background: 'white',
border: '1px solid #ccc',
borderRadius: 4,
padding: '0.5rem',
minWidth: '210px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter') {
setIsDropOpen(false);
}
}}
>
<Button
aria-label={t('Delete this invitation')}
onClick={() => {
deleteMailDomainInvitation({
slug: mailDomain.slug,
invitationId: access.id,
});
setIsDropOpen(false);
}}
color="primary-text"
size="small"
fullWidth
icon={
<span className="material-icons" aria-hidden="true">
delete
</span>
}
>
<Text $theme="primary">{t('Delete invitation')}</Text>
</Button>
</div>
)}
</div>
);
};

View File

@@ -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);