✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 ')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user