(front) create, manage & delete aliases (#1013)

* (front) add aliases

add list view aliases + creation aliases

* (front) add delete alias

add modale to delete aliases

* 🐛(react-query) remove onMutateResult from mutation callbacks

remove onMutateResult from mutation callbacks
This commit is contained in:
elvoisin
2026-01-19 17:04:57 +01:00
committed by GitHub
parent 54219d25b8
commit eb0683ffe0
32 changed files with 2005 additions and 106 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added ### Added
- ✨(front) create, manage & delete aliases
- ✨(domains) alias sorting and admin - ✨(domains) alias sorting and admin
- ✨(aliases) delete all aliases in one call #1002 - ✨(aliases) delete all aliases in one call #1002

View File

@@ -1 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071 NEXT_PUBLIC_API_ORIGIN=http://localhost:8071

View File

@@ -19,13 +19,17 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
> >
{label} {required && '*'} {label} {required && '*'}
</label> </label>
{error && (
<Text $size="xs" $theme="danger" $variation="600">
{error}
</Text>
)}
<input <input
id={label} id={label}
aria-required={required} aria-required={required}
required={required} required={required}
style={{ style={{
padding: '12px', padding: '12px',
margin: '6px 0',
borderRadius: '4px', borderRadius: '4px',
fontSize: '14px', fontSize: '14px',
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`, border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
@@ -34,11 +38,6 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
}} }}
{...props} {...props}
/> />
{error && (
<Text $size="xs" $color="error-500">
{error}
</Text>
)}
</Box> </Box>
); );
}; };

View File

@@ -11,3 +11,4 @@ export * from './Tag';
export * from './Text'; export * from './Text';
export * from './TextErrors'; export * from './TextErrors';
export * from './separators'; export * from './separators';
export * from './tabs/CustomTabs';

View File

@@ -27,7 +27,7 @@ export const CustomTabs = ({ tabs }: Props) => {
const id = tab.id ?? tab.label; const id = tab.id ?? tab.label;
return ( return (
<Tab key={id} aria-label={tab.ariaLabel} id={id}> <Tab key={id} aria-label={tab.ariaLabel} id={id}>
<Box $direction="row" $align="center" $gap="5px"> <Box $direction="row" $gap="5px">
{tab.iconName && ( {tab.iconName && (
<span className="material-icons" aria-hidden="true"> <span className="material-icons" aria-hidden="true">
{tab.iconName} {tab.iconName}

View File

@@ -1,63 +1,69 @@
.customTabsContainer { .customTabsContainer {
:global { display: flex;
.react-aria-TabList { width: 100%;
display: flex; margin-top: 30px;
&[data-orientation='horizontal'] { :global(.react-aria-Tabs) {
.react-aria-Tab { width: 100%;
border-bottom: 2px solid var(--c--theme--colors--greyscale-500); display: flex;
} flex: 1;
}
&[data-orientation='horizontal'] {
flex-direction: column;
}
}
:global(.react-aria-TabList) {
display: flex;
width: 100%;
gap: 25px;
}
:global(.react-aria-Tab) {
display: flex;
padding: 0 10px 10px 10px;
cursor: pointer;
outline: none;
position: relative;
color: var(--c--theme--colors--secondary-900);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered] {
background-color: #fff;
color: var(--text-color-hover);
} }
.react-aria-Tab { &[data-selected] {
padding: 10px; font-weight: 500;
cursor: pointer; color: var(--c--theme--colors--primary-text);
outline: none; border-bottom: 2px solid var(--c--theme--colors--primary-text);
position: relative; }
color: var(--c--theme--colors--greyscale-700);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered],
&[data-focused] {
color: var(--c--theme--colors--greyscale-900);
}
&[data-disabled] {
color: var(--text-color-disabled);
&[data-selected] { &[data-selected] {
border-bottom: 2px solid var(--c--theme--colors--primary-600) !important; --border-color: var(--text-color-disabled);
color: var(--c--theme--colors--primary-600);
}
&[data-disabled] {
color: var(--c--theme--colors--greyscale-500);
&[data-selected] {
--border-color: var(--c--theme--colors--greyscale-200);
}
}
&[data-focus-visible]::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 4px;
border: 1px solid var(--c--theme--colors--primary-600);
} }
} }
.react-aria-TabPanel { &[data-focus-visible]:after {
margin-top: 4px; content: '';
padding: 10px; position: absolute;
inset: 4px;
border-radius: 4px; border-radius: 4px;
outline: none; border: 2px solid var(--focus-ring-color);
}
}
&[data-focus-visible] { :global(.react-aria-TabPanel) {
outline: 2px solid var(--c--theme--colors--primary-600); margin-top: 15px;
} border-radius: 4px;
outline: none;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
} }
} }
} }

View File

@@ -75,8 +75,7 @@ describe('useDeleteMailDomainAccess', () => {
expect(onSuccess).toHaveBeenCalledWith( expect(onSuccess).toHaveBeenCalledWith(
undefined, undefined,
{ slug: 'example-slug', accessId: '1-1-1-1-1' }, { slug: 'example-slug', accessId: '1-1-1-1-1' },
undefined, undefined, // context
{ client: {}, meta: undefined, mutationKey: undefined },
), ),
); );
expect(fetchMock.lastUrl()).toContain( expect(fetchMock.lastUrl()).toContain(

View File

@@ -104,8 +104,7 @@ describe('useUpdateMailDomainAccess', () => {
expect(onSuccess).toHaveBeenCalledWith( expect(onSuccess).toHaveBeenCalledWith(
mockResponse, // data mockResponse, // data
{ slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables { slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables
undefined, // onMutateResult undefined, // context
{ client: {}, meta: undefined, mutationKey: undefined }, // context
), ),
); );
expect(fetchMock.lastUrl()).toContain( expect(fetchMock.lastUrl()).toContain(

View File

@@ -44,19 +44,42 @@ export const useCreateMailDomainAccess = (
options?: UseCreateMailDomainAccessOptions, options?: UseCreateMailDomainAccessOptions,
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<Access, APIError, CreateMailDomainAccessProps>({ return useMutation<Access, APIError, CreateMailDomainAccessProps>({
mutationFn: createMailDomainAccess, mutationFn: createMailDomainAccess,
...options, ...restOptions,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES], queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
}); });
void queryClient.invalidateQueries({ queryKey: [KEY_MAIL_DOMAIN] }); void queryClient.invalidateQueries({ queryKey: [KEY_MAIL_DOMAIN] });
options?.onSuccess?.(data, variables, onMutateResult, context); if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: Access,
variables: CreateMailDomainAccessProps,
context: unknown,
) => void
)(data, variables, context);
}
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
options?.onError?.(error, variables, onMutateResult, context); if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: CreateMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
}, },
}); });
}; };

View File

@@ -46,10 +46,15 @@ export const useDeleteMailDomainAccess = (
options?: UseDeleteMailDomainAccessOptions, options?: UseDeleteMailDomainAccessOptions,
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteMailDomainAccessProps>({ return useMutation<void, APIError, DeleteMailDomainAccessProps>({
mutationFn: deleteMailDomainAccess, mutationFn: deleteMailDomainAccess,
...options, ...restOptions,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES], queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
}); });
@@ -59,13 +64,27 @@ export const useDeleteMailDomainAccess = (
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN], queryKey: [KEY_LIST_MAIL_DOMAIN],
}); });
if (options?.onSuccess) { if (optionsOnSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: DeleteMailDomainAccessProps,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (optionsOnError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });

View File

@@ -51,23 +51,42 @@ export const useUpdateMailDomainAccess = (
options?: UseUpdateMailDomainAccessOptions, options?: UseUpdateMailDomainAccessOptions,
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({ return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
mutationFn: updateMailDomainAccess, mutationFn: updateMailDomainAccess,
...options, ...restOptions,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES], queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
}); });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_MAIL_DOMAIN], queryKey: [KEY_MAIL_DOMAIN],
}); });
if (options?.onSuccess) { if (optionsOnSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: Access,
variables: UpdateMailDomainAccessProps,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (optionsOnError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: UpdateMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });

View File

@@ -0,0 +1,4 @@
export * from './useAliases';
export * from './useAliasesInfinite';
export * from './useCreateAlias';
export * from './useDeleteAlias';

View File

@@ -0,0 +1,57 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Alias } from '../types';
export type MailDomainAliasesParams = {
mailDomainSlug: string;
page: number;
ordering?: string;
};
type MailDomainAliasesResponse = APIList<Alias>;
export const getMailDomainAliases = async ({
mailDomainSlug,
page,
ordering,
}: MailDomainAliasesParams): Promise<MailDomainAliasesResponse> => {
let url = `mail-domains/${mailDomainSlug}/aliases/?page=${page}`;
if (ordering) {
url += '&ordering=' + ordering;
}
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
`Failed to get the aliases of mail domain ${mailDomainSlug}`,
await errorCauses(response),
);
}
return response.json() as Promise<MailDomainAliasesResponse>;
};
export const KEY_LIST_ALIAS = 'aliases';
export function useAliases(
param: MailDomainAliasesParams,
queryConfig?: UseQueryOptions<
MailDomainAliasesResponse,
APIError,
MailDomainAliasesResponse
>,
) {
return useQuery<
MailDomainAliasesResponse,
APIError,
MailDomainAliasesResponse
>({
queryKey: [KEY_LIST_ALIAS, param],
queryFn: () => getMailDomainAliases(param),
...queryConfig,
});
}

View File

@@ -0,0 +1,32 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { KEY_LIST_ALIAS, getMailDomainAliases } from './useAliases';
export type MailDomainAliasesInfiniteParams = {
mailDomainSlug: string;
ordering?: string;
};
export function useAliasesInfinite(
param: MailDomainAliasesInfiniteParams,
queryConfig = {},
) {
return useInfiniteQuery({
initialPageParam: 1,
queryKey: [KEY_LIST_ALIAS, param],
queryFn: ({ pageParam }) =>
getMailDomainAliases({
mailDomainSlug: param.mailDomainSlug,
page: pageParam,
ordering: param.ordering,
}),
getNextPageParam(lastPage, allPages) {
// When there is no more page, return undefined
if (!lastPage.next) {
return undefined;
}
return allPages.length + 1;
},
...queryConfig,
});
}

View File

@@ -0,0 +1,83 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
export interface CreateAliasParams {
local_part: string;
destination: string;
mailDomainSlug: string;
}
export const createAlias = async ({
mailDomainSlug,
...data
}: CreateAliasParams): Promise<void> => {
const response = await fetchAPI(`mail-domains/${mailDomainSlug}/aliases/`, {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await errorCauses(response);
throw new APIError('Failed to create the alias', {
status: errorData.status,
cause: errorData.cause as string[],
data: errorData.data,
});
}
};
type UseCreateAliasParams = { mailDomainSlug: string } & UseMutationOptions<
void,
APIError,
CreateAliasParams
>;
export const useCreateAlias = (options: UseCreateAliasParams) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options;
return useMutation<void, APIError, CreateAliasParams>({
mutationFn: createAlias,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [
KEY_LIST_ALIAS,
{ mailDomainSlug: variables.mailDomainSlug },
],
});
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: CreateAliasParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: CreateAliasParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};

View File

@@ -0,0 +1,79 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
interface DeleteAliasParams {
mailDomainSlug: string;
localPart: string;
}
export const deleteAlias = async ({
mailDomainSlug,
localPart,
}: DeleteAliasParams): Promise<void> => {
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/aliases/delete/?local_part=${encodeURIComponent(localPart)}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the alias',
await errorCauses(response),
);
}
};
type UseDeleteAliasOptions = UseMutationOptions<
void,
APIError,
DeleteAliasParams
>;
export const useDeleteAlias = (options?: UseDeleteAliasOptions) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteAliasParams>({
mutationFn: deleteAlias,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_ALIAS],
});
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: DeleteAliasParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteAliasParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};

View File

@@ -0,0 +1,75 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
interface DeleteAliasByIdParams {
mailDomainSlug: string;
aliasId: string;
}
export const deleteAliasById = async ({
mailDomainSlug,
aliasId,
}: DeleteAliasByIdParams): Promise<void> => {
// Use aliasId (pk) directly in URL as per API lookup_field = "pk"
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/aliases/${aliasId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the alias',
await errorCauses(response),
);
}
};
type UseDeleteAliasByIdOptions = UseMutationOptions<
void,
APIError,
DeleteAliasByIdParams
>;
export const useDeleteAliasById = (options?: UseDeleteAliasByIdOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteAliasByIdParams>({
mutationFn: deleteAliasById,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_ALIAS],
});
if (options?.onSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: DeleteAliasByIdParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: DeleteAliasByIdParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};

View File

@@ -0,0 +1,135 @@
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCunninghamTheme } from '@/cunningham';
import { ModalCreateAlias } from '@/features/mail-domains/aliases/components';
import { AliasesListView } from '@/features/mail-domains/aliases/components/panel';
import { MailDomain } from '../../domains/types';
export function AliasesView({ mailDomain }: { mailDomain: MailDomain }) {
const [searchValue, setSearchValue] = useState('');
const [isCreateAliasFormVisible, setIsCreateAliasFormVisible] =
useState(false);
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const colors = colorsTokens();
const canCreateAlias = mailDomain.status === 'enabled';
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
};
const clearInput = () => {
setSearchValue('');
};
const openModal = () => {
setIsCreateAliasFormVisible(true);
};
return (
<>
<div aria-label="Aliases panel" className="container">
<h3 style={{ fontWeight: 700, fontSize: '18px', marginBottom: 'base' }}>
{t('Aliases')}
</h3>
<div
className="sm:block md:flex"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
gap: '1em',
}}
>
<div
style={{ width: 'calc(100% - 245px)' }}
className="c__input__wrapper__mobile"
>
<Input
style={{ width: '100%' }}
label={t('Search for an alias')}
icon={<span className="material-icons">search</span>}
rightIcon={
searchValue && (
<span
className="material-icons"
onClick={clearInput}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
clearInput();
}
}}
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
>
close
</span>
)
}
value={searchValue}
onChange={handleInputChange}
/>
</div>
<div
className="hidden md:flex"
style={{
background: colors['greyscale-200'],
height: '32px',
width: '1px',
}}
></div>
<div
className="block md:hidden"
style={{ marginBottom: '10px' }}
></div>
<div>
{mailDomain?.abilities.post ? (
<Button
data-testid="button-new-alias"
aria-label={t('Create an alias in {{name}} domain', {
name: mailDomain?.name,
})}
disabled={!canCreateAlias}
onClick={() => setIsCreateAliasFormVisible(true)}
>
{t('New alias')}
</Button>
) : (
<Tooltip content={t("You don't have the correct access right")}>
<div>
<Button
data-testid="button-new-alias"
onClick={openModal}
disabled={!isCreateAliasFormVisible}
>
{t('New alias')}
</Button>
</div>
</Tooltip>
)}
</div>
</div>
<AliasesListView mailDomain={mailDomain} querySearch={searchValue} />
{isCreateAliasFormVisible && mailDomain ? (
<ModalCreateAlias
mailDomain={mailDomain}
closeModal={() => setIsCreateAliasFormVisible(false)}
/>
) : null}
</div>
</>
);
}

View File

@@ -0,0 +1,438 @@
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Icon,
Input,
Text,
TextErrors,
} from '@/components';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useCreateAlias } from '../api';
const FORM_ID = 'form-create-alias';
export const ModalCreateAlias = ({
mailDomain,
closeModal,
}: {
mailDomain: MailDomain;
closeModal: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [destinations, setDestinations] = useState<string[]>([]);
const [newDestination, setNewDestination] = useState('');
const [destinationError, setDestinationError] = useState<string | null>(null);
type AliasFormData = {
local_part: string;
};
const createAliasValidationSchema: z.ZodType<AliasFormData> = z.object({
local_part: z
.string()
.regex(/^((?!@|\s)([a-zA-Z0-9.\-]))*$/, t('Invalid format'))
.min(1, t('You must have minimum 1 character')),
});
const methods = useForm<AliasFormData>({
resolver: standardSchemaResolver(createAliasValidationSchema),
defaultValues: {
local_part: '',
},
mode: 'onChange',
});
const addDestination = () => {
const trimmed = newDestination.trim();
if (!trimmed) {
setDestinationError(t('Please enter an email address'));
return;
}
// Validation email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
setDestinationError(t('Please enter a valid email address'));
return;
}
// Vérifier si déjà présent
if (destinations.includes(trimmed)) {
setDestinationError(t('This email address is already in the list'));
return;
}
setDestinations([...destinations, trimmed]);
setNewDestination('');
setDestinationError(null);
};
const removeDestination = (index: number) => {
setDestinations(destinations.filter((_, i) => i !== index));
};
const handleDestinationKeyPress = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
addDestination();
}
};
const { mutate: createAlias } = useCreateAlias({
mailDomainSlug: mailDomain.slug,
});
const onSubmitCallback = async (event: React.FormEvent) => {
event.preventDefault();
const isValid = await methods.trigger();
if (!isValid) {
return;
}
if (destinations.length === 0) {
toast(t('Please add at least one destination email'), VariantType.ERROR, {
duration: 4000,
});
return;
}
const data = methods.getValues();
setIsSubmitting(true);
setErrorCauses([]);
let successCount = 0;
let errorCount = 0;
const allErrors: string[] = [];
for (const destination of destinations) {
try {
await new Promise<void>((resolve, reject) => {
createAlias(
{
local_part: data.local_part,
destination: destination.trim(),
mailDomainSlug: mailDomain.slug,
},
{
onSuccess: () => {
successCount++;
resolve();
},
onError: (error) => {
errorCount++;
const causes =
parseAPIError({
error,
errorParams: [
[
['Local part ".*" already used by a mailbox.'],
t('This email prefix is already used by a mailbox.'),
undefined,
],
[
['Invalid format'],
t('Invalid format for the email prefix.'),
undefined,
],
],
serverErrorParams: [
t(
'The domain must be enabled to create aliases. Please check the domain status.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
allErrors.push(...causes);
}
reject(error);
},
},
);
});
} catch {
// Erreur déjà gérée dans onError
}
}
setIsSubmitting(false);
// Afficher les résultats
if (errorCount > 0) {
setErrorCauses(allErrors);
}
if (successCount === destinations.length) {
toast(
t('All {{count}} alias(es) created successfully!', {
count: successCount,
}),
VariantType.SUCCESS,
{ duration: 4000 },
);
closeModal();
} else if (successCount > 0) {
toast(
t('{{success}} alias(es) created, {{errors}} failed', {
success: successCount,
errors: errorCount,
}),
VariantType.WARNING,
{ duration: 5000 },
);
} else {
toast(t('Failed to create aliases'), VariantType.ERROR, {
duration: 4000,
});
}
};
const steps = [
{
title: t('New alias'),
content: (
<FormProvider {...methods}>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form
id={FORM_ID}
onSubmit={(e) => {
void onSubmitCallback(e);
}}
>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Alias configuration')}
</Text>
<Text $theme="greyscale" $variation="600">
{t(
'An alias allows you to redirect emails to one or more addresses.',
)}
</Text>
</Box>
<Box
$padding="md"
style={{
position: 'relative',
alignItems: 'end',
gap: '20px',
flexDirection: 'row',
alignContent: 'flex-end',
}}
>
<Controller
name="local_part"
control={methods.control}
render={({ field }) => (
<Box $align="center">
<Input
{...field}
label={t('Name of the alias')}
required
placeholder={t('contact')}
/>
</Box>
)}
/>
<Box
style={{
display: 'flex',
position: 'absolute',
top: '58px',
left: '210px',
}}
>
<Text className="mb-8" $weight="500">
@{mailDomain.name}
</Text>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
<Text $size="sm" $weight="500">
{t('Destination email addresses')}
</Text>
<Box $gap="4px">
<Box $direction="row" $gap="8px" $align="end">
<Box style={{ flex: 1 }}>
<Input
value={newDestination}
onChange={(e) => {
setNewDestination(e.target.value);
setDestinationError(null);
}}
onKeyPress={handleDestinationKeyPress}
label={t('Add destination email')}
placeholder=""
/>
</Box>
<Button
type="button"
onClick={addDestination}
disabled={!newDestination.trim()}
>
{t('Add destination')}
</Button>
</Box>
{destinationError && (
<Text $theme="warning" $size="sm">
{destinationError}
</Text>
)}
</Box>
{/* Destinations Array */}
{destinations.length > 0 && (
<Box
$margin={{ top: 'md' }}
style={{
border:
'1px solid var(--c--contextuals--border--surface--primary)',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<table
style={{ width: '100%', borderCollapse: 'collapse' }}
>
<thead>
<tr
style={{
paddingBottom: '12px',
borderBottom:
'1px solid var(--c--contextuals--border--surface--primary)',
}}
>
<th
style={{
textAlign: 'left',
fontWeight: 500,
fontSize: '14px',
}}
>
{t('Email address')}
</th>
<th
style={{
paddingBottom: '12px',
textAlign: 'right',
width: '80px',
}}
>
{t('Actions')}
</th>
</tr>
</thead>
<tbody>
{destinations.map((destination, index) => (
<tr
key={index}
style={{
paddingBottom: '12px',
borderBottom:
index < destinations.length - 1
? '1px solid var(--c--contextuals--border--surface--primary)'
: 'none',
}}
>
<td>
<Text $size="sm">{destination}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Button
type="button"
color="tertiary"
onClick={() => removeDestination(index)}
aria-label={t('Remove destination')}
icon={<Icon iconName="delete" />}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
</Box>
</form>
</FormProvider>
),
leftAction: (
<Button color="secondary" onClick={closeModal}>
{t('Cancel')}
</Button>
),
rightAction: (
<Button
type="submit"
form={FORM_ID}
disabled={
!methods.formState.isValid ||
destinations.length === 0 ||
isSubmitting
}
>
{isSubmitting ? t('Creating...') : t('Create alias')}
</Button>
),
},
];
return (
<div id="modal-new-alias">
<CustomModal
isOpen
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={closeModal}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isSubmitting && (
<Box $align="center" $padding="md">
<Loader />
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
{t('Creating alias...')}
</Text>
</Box>
)}
</CustomModal>
</div>
);
};

View File

@@ -0,0 +1,578 @@
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Icon,
Input,
Text,
TextErrors,
} from '@/components';
import { Modal } from '@/components/Modal';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useCreateAlias } from '../api/useCreateAlias';
import { useDeleteAlias } from '../api/useDeleteAlias';
import { useDeleteAliasById } from '../api/useDeleteAliasById';
import { AliasGroup } from '../types';
const FORM_ID = 'form-edit-alias';
export const ModalEditAlias = ({
mailDomain,
aliasGroup,
closeModal,
}: {
mailDomain: MailDomain;
aliasGroup: AliasGroup;
closeModal: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [destinations, setDestinations] = useState<string[]>([]);
const [newDestination, setNewDestination] = useState('');
const [destinationError, setDestinationError] = useState<string | undefined>(
undefined,
);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
});
useEffect(() => {
setDestinations([...aliasGroup.destinations]);
}, [aliasGroup]);
const addDestination = async () => {
const trimmed = newDestination.trim();
if (!trimmed) {
setDestinationError(t('Please enter an email address'));
return;
}
// Valid email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
setDestinationError(t('Please enter a valid email address'));
return;
}
// Email already in list
if (destinations.includes(trimmed)) {
setDestinationError(t('This email address is already in the list'));
return;
}
// Check domain enabled
if (mailDomain.status !== 'enabled') {
setDestinationError(
t(
'The domain must be enabled to add destinations. Current status: {{status}}',
{
status: mailDomain.status,
},
),
);
return;
}
setIsSubmitting(true);
setDestinationError(undefined);
try {
await new Promise<void>((resolve, reject) => {
createAlias(
{
local_part: aliasGroup.local_part,
destination: trimmed,
mailDomainSlug: mailDomain.slug,
},
{
onSuccess: () => {
toast(t('Destination added successfully'), VariantType.SUCCESS, {
duration: 4000,
});
setDestinations([...destinations, trimmed]);
setNewDestination('');
resolve();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [
[
['Local part ".*" already used by a mailbox.'],
t('This email prefix is already used by a mailbox.'),
undefined,
],
[
['Invalid format'],
t('Invalid format for the email prefix.'),
undefined,
],
],
serverErrorParams: [
t(
'The domain must be enabled to add destinations. Please check the domain status.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setDestinationError(causes[0]);
} else {
setDestinationError(
t('Failed to add destination. Please try again.'),
);
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const removeDestination = (destination: string) => {
setConfirmModal({
isOpen: true,
title: t('Remove destination'),
message: t(
'Are you sure you want to remove {{destination}} from this alias?',
{ destination },
),
onConfirm: () => {
setConfirmModal({ ...confirmModal, isOpen: false });
void handleRemoveDestination(destination);
},
});
};
const handleRemoveDestination = async (destination: string) => {
setIsSubmitting(true);
const aliasId = aliasGroup.destinationIds[destination];
if (!aliasId) {
toast(
t('Failed to find alias ID for this destination'),
VariantType.ERROR,
{
duration: 4000,
},
);
setIsSubmitting(false);
return;
}
try {
await new Promise<void>((resolve, reject) => {
deleteAliasById(
{
mailDomainSlug: mailDomain.slug,
aliasId,
},
{
onSuccess: () => {
toast(
t('Destination removed successfully'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
setDestinations(destinations.filter((d) => d !== destination));
resolve();
closeModal();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [],
serverErrorParams: [
t(
'An error occurred while removing the destination. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setDestinationError(causes[0]);
} else {
toast(t('Failed to remove destination'), VariantType.ERROR, {
duration: 4000,
});
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const handleRemoveAllDestinations = () => {
setConfirmModal({
isOpen: true,
title: t('Delete this alias'),
message: t(
'Are you sure you want to remove all destinations from this alias? This action cannot be undone.',
),
onConfirm: () => {
setConfirmModal({ ...confirmModal, isOpen: false });
void removeAllDestinations();
},
});
};
const removeAllDestinations = async () => {
if (destinations.length === 0) {
return;
}
setIsSubmitting(true);
setErrorCauses([]);
try {
await new Promise<void>((resolve, reject) => {
deleteAlias(
{
mailDomainSlug: mailDomain.slug,
localPart: aliasGroup.local_part,
},
{
onSuccess: () => {
toast(t('Alias deleted successfully'), VariantType.SUCCESS, {
duration: 4000,
});
setDestinations([]);
resolve();
closeModal();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [],
serverErrorParams: [
t(
'An error occurred while deleting the alias. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setErrorCauses(causes);
} else {
toast(t('Failed to delete alias'), VariantType.ERROR, {
duration: 4000,
});
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const handleDestinationKeyPress = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
void addDestination();
}
};
const { mutate: createAlias } = useCreateAlias({
mailDomainSlug: mailDomain.slug,
});
const { mutate: deleteAliasById } = useDeleteAliasById();
const { mutate: deleteAlias } = useDeleteAlias();
const steps = [
{
title: t('Manage alias'),
content: (
<>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
void addDestination();
}}
>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Alias configuration')}
</Text>
<Text $theme="greyscale" $variation="600">
{t('Manage the destination email addresses for this alias.')}
</Text>
</Box>
<Box
$padding="md"
style={{
position: 'relative',
alignItems: 'end',
gap: '20px',
flexDirection: 'row',
alignContent: 'flex-end',
}}
>
<Box>
<Input
value={aliasGroup.local_part}
label={t('Name of the alias')}
disabled
readOnly
/>
</Box>
<Box
style={{
display: 'flex',
position: 'absolute',
top: '58px',
left: '210px',
}}
>
<Text className="mb-8" $weight="500">
@{mailDomain.name}
</Text>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
<Text $size="sm" $weight="500">
{t('Destination email addresses')}
</Text>
<Box $gap="4px">
<Box $direction="row" $gap="8px" $align="end">
<Box style={{ flex: 1 }}>
<Input
value={newDestination}
onChange={(e) => {
setNewDestination(e.target.value);
setDestinationError(undefined);
}}
onKeyPress={handleDestinationKeyPress}
error={destinationError}
label={t('Add destination email')}
placeholder={t('john.appleseed@example.fr')}
/>
</Box>
<Button
type="submit"
form={FORM_ID}
disabled={!newDestination.trim() || isSubmitting}
>
{isSubmitting ? t('Adding...') : t('Add')}
</Button>
</Box>
</Box>
{/* Tableau des destinations */}
{destinations.length > 0 && (
<Box
$margin={{ top: 'md' }}
$css={`
table tbody tr {
transition: background-color 0.2s ease;
cursor: pointer;
}
table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.04);
}
`}
>
<table
style={{ width: '100%', borderCollapse: 'collapse' }}
>
<thead>
<tr
style={{
paddingBottom: '12px',
borderBottom:
'1px solid var(--c--contextuals--border--surface--primary)',
}}
>
<th
style={{
textAlign: 'left',
fontWeight: 500,
fontSize: '14px',
}}
>
{t('Email address')}
</th>
<th
style={{
paddingBottom: '12px',
textAlign: 'right',
width: '80px',
}}
>
{t('Actions')}
</th>
</tr>
</thead>
<tbody>
{destinations.map((destination, index) => (
<tr
key={index}
style={{
paddingBottom: '12px',
marginBottom: '12px',
}}
>
<td style={{ paddingLeft: '12px' }}>
<Text $size="sm">{destination}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Button
type="button"
color="tertiary"
onClick={() => removeDestination(destination)}
aria-label={t('Remove destination')}
icon={<Icon iconName="delete" />}
disabled={isSubmitting}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
</Box>
</form>
</>
),
leftAction: (
<Button color="secondary" onClick={closeModal}>
{t('Close')}
</Button>
),
rightAction: (
<Box $direction="row" $gap="6px">
{destinations.length > 0 && (
<Button
type="button"
color="danger"
onClick={handleRemoveAllDestinations}
disabled={isSubmitting}
icon={
<Icon iconName="delete" $theme="greyscale" $variation="000" />
}
>
{t('Delete this alias')}
</Button>
)}
</Box>
),
},
];
return (
<div id="modal-edit-alias">
<CustomModal
isOpen
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={closeModal}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isSubmitting && (
<Box $align="center" $padding="md">
<Loader />
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
{t('Updating alias...')}
</Text>
</Box>
)}
</CustomModal>
{/* Confirmation Modal */}
<Modal
isOpen={confirmModal.isOpen}
closeOnClickOutside
hideCloseButton
leftActions={
<Button
color="secondary"
fullWidth
onClick={() => setConfirmModal({ ...confirmModal, isOpen: false })}
>
{t('Cancel')}
</Button>
}
rightActions={
<Button
color="danger"
fullWidth
onClick={confirmModal.onConfirm}
disabled={isSubmitting}
>
{t('Confirm')}
</Button>
}
size={ModalSize.MEDIUM}
title={confirmModal.title}
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
>
<Box $padding="md">
<Text>{confirmModal.message}</Text>
</Box>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,3 @@
export * from './AliasesView';
export * from './ModalCreateAlias';
export * from './ModalEditAlias';

View File

@@ -0,0 +1,222 @@
import { Button, DataGrid, SortModel } from '@openfun/cunningham-react';
import type { InfiniteData } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Alias, AliasGroup } from '@/features/mail-domains/aliases/types';
import { MailDomain } from '@/features/mail-domains/domains';
import { useAliasesInfinite } from '../../api/useAliasesInfinite';
import { ModalEditAlias } from '../ModalEditAlias';
type MailDomainAliasesResponse = {
count: number;
next: string | null;
previous: string | null;
results: Alias[];
};
interface AliasesListViewProps {
mailDomain: MailDomain;
querySearch: string;
}
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem) {
return sortModel.sort === 'desc' ? `-${sortModel.field}` : sortModel.field;
}
export function AliasesListView({
mailDomain,
querySearch,
}: AliasesListViewProps) {
const { t } = useTranslation();
const { userData } = useAuthStore();
const [editingAliasGroup, setEditingAliasGroup] = useState<AliasGroup | null>(
null,
);
const [sortModel] = useState<SortModel>([]);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useAliasesInfinite({
mailDomainSlug: mailDomain.slug,
ordering,
}) as {
data: InfiniteData<MailDomainAliasesResponse, number> | undefined;
isLoading: boolean;
error: { cause?: string[] };
fetchNextPage: () => void;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
};
const aliasGroups: AliasGroup[] = useMemo(() => {
if (!mailDomain || !data?.pages?.length) {
return [];
}
const grouped = new Map<string, AliasGroup>();
data.pages.forEach((page) => {
page.results.forEach((alias: Alias) => {
const email = `${alias.local_part}@${mailDomain.name}`;
const existing = grouped.get(alias.local_part);
if (existing) {
if (!existing.destinations.includes(alias.destination)) {
existing.destinations.push(alias.destination);
existing.destinationIds[alias.destination] = alias.id;
existing.count_destinations = existing.destinations.length;
}
} else {
const destinationIds: Record<string, string> = {};
destinationIds[alias.destination] = alias.id;
grouped.set(alias.local_part, {
id: alias.local_part,
email,
local_part: alias.local_part,
destinations: [alias.destination],
destinationIds,
count_destinations: 1,
});
}
});
});
return Array.from(grouped.values());
}, [data, mailDomain]);
const filteredAliases = useMemo(() => {
if (!querySearch) {
return aliasGroups;
}
const lowerCaseSearch = querySearch.toLowerCase();
return aliasGroups.filter(
(alias) =>
alias.email.toLowerCase().includes(lowerCaseSearch) ||
alias.destinations.some((dest) =>
dest.toLowerCase().includes(lowerCaseSearch),
),
);
}, [querySearch, aliasGroups]);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasNextPage) {
return;
}
const ref = loadMoreRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1 },
);
if (ref) {
observer.observe(ref);
}
return () => {
if (ref) {
observer.unobserve(ref);
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{error && <TextErrors causes={error.cause ?? []} />}
{!filteredAliases.length && (
<Text $align="center" $size="small" $padding={{ top: 'base' }}>
{t('No alias was created with this mail domain.')}
</Text>
)}
{filteredAliases && filteredAliases.length ? (
<>
<DataGrid
aria-label="aliaslist"
rows={filteredAliases}
columns={[
{
field: 'email',
headerName: `${t('Alias')}${filteredAliases.length}`,
renderCell: ({ row }) => (
<Text $weight="400" $theme="greyscale">
{row.email}
</Text>
),
},
{
field: 'destinations',
headerName: t('Destinations'),
enableSorting: false,
renderCell: ({ row }) => (
<Text $weight="500" $theme="greyscale">
{row.count_destinations} destination
{row.count_destinations > 1 ? 's' : ''}
</Text>
),
},
{
id: 'actions',
renderCell: ({ row }) => {
// Check if user can edit
const isOwnerOrAdmin =
mailDomain.abilities?.patch || mailDomain.abilities?.put;
const isAliasDestination = row.destinations.some(
(dest) => dest === userData?.email,
);
const canEdit = isOwnerOrAdmin || isAliasDestination;
return (
<Box $direction="row" $gap="sm" $align="center">
{canEdit && (
<Button
color="tertiary"
onClick={() => setEditingAliasGroup(row)}
style={{
fontWeight: '500',
fontSize: '16px',
}}
>
{t('Manage')}
</Button>
)}
</Box>
);
},
},
]}
isLoading={isLoading}
/>
{isFetchingNextPage && <div>{t('Loading more...')}</div>}
</>
) : null}
<div ref={loadMoreRef} />
{editingAliasGroup && (
<ModalEditAlias
mailDomain={mailDomain}
aliasGroup={editingAliasGroup}
closeModal={() => setEditingAliasGroup(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './AliasesListView';

View File

@@ -0,0 +1,3 @@
export * from './api';
export * from './components';
export * from './types';

View File

@@ -0,0 +1,24 @@
import { UUID } from 'crypto';
export interface Alias {
id: UUID;
local_part: string;
destination: string;
}
export interface ViewAlias {
id: string;
email: string;
local_part: string;
destination: string;
alias: Alias;
}
export interface AliasGroup {
id: string;
email: string;
local_part: string;
destinations: string[];
destinationIds: Record<string, string>;
count_destinations: number;
}

View File

@@ -3,8 +3,10 @@ import { useRouter } from 'next/navigation';
import * as React from 'react'; import * as React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Tag, Text } from '@/components'; import { Box, CustomTabs, Tag, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { AliasesView } from '@/features/mail-domains/aliases';
import { useAliases } from '@/features/mail-domains/aliases/api/useAliases';
import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.svg'; import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.svg';
import { import {
MailDomain, MailDomain,
@@ -13,6 +15,7 @@ import {
Role, Role,
} from '@/features/mail-domains/domains'; } from '@/features/mail-domains/domains';
import { MailBoxesView } from '@/features/mail-domains/mailboxes'; import { MailBoxesView } from '@/features/mail-domains/mailboxes';
import { useMailboxes } from '@/features/mail-domains/mailboxes/api/useMailboxes';
type Props = { type Props = {
mailDomain: MailDomain; mailDomain: MailDomain;
@@ -30,6 +33,18 @@ export const MailDomainView = ({
const router = useRouter(); const router = useRouter();
const [showModal, setShowModal] = React.useState(false); const [showModal, setShowModal] = React.useState(false);
const { data: mailboxesData } = useMailboxes({
mailDomainSlug: mailDomain.slug,
page: 1,
});
const { data: aliasesData } = useAliases({
mailDomainSlug: mailDomain.slug,
page: 1,
});
const countMailboxes = mailboxesData?.count ?? 0;
const countAliases = aliasesData?.count ?? 0;
const handleShowModal = () => { const handleShowModal = () => {
setShowModal(true); setShowModal(true);
}; };
@@ -110,15 +125,27 @@ export const MailDomainView = ({
$padding={{ horizontal: 'md' }} $padding={{ horizontal: 'md' }}
$margin={{ top: 'md' }} $margin={{ top: 'md' }}
$background="white" $background="white"
$align="center"
$gap="8px"
$radius="4px" $radius="4px"
$direction="row"
$css={` $css={`
border: 1px solid ${colorsTokens()['greyscale-200']}; border: 1px solid ${colorsTokens()['greyscale-200']};
`} `}
> >
<MailBoxesView mailDomain={mailDomain} /> <CustomTabs
tabs={[
{
id: 'mailboxes',
label: t('Email addresses') + ` (${countMailboxes})`,
iconName: 'mail',
content: <MailBoxesView mailDomain={mailDomain} />,
},
{
id: 'aliases',
label: t('Aliases') + ` (${countAliases})`,
iconName: 'forward_to_inbox',
content: <AliasesView mailDomain={mailDomain} />,
},
]}
/>
</Box> </Box>
</Box> </Box>
</> </>

View File

@@ -27,7 +27,7 @@ export const ModalAddMailDomain = ({
const addMailDomainValidationSchema = z.object({ const addMailDomainValidationSchema = z.object({
name: z.string().min(1, t('Example: saint-laurent.fr')), name: z.string().min(1, t('Example: saint-laurent.fr')),
supportEmail: z.email(t('Please enter a valid email address')), supportEmail: z.string().email(t('Please enter a valid email address')),
}); });
const methods = useForm<{ name: string; supportEmail: string }>({ const methods = useForm<{ name: string; supportEmail: string }>({

View File

@@ -46,7 +46,8 @@ export const useCreateMailbox = (options: UseCreateMailboxParams) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<void, APIError, CreateMailboxParams>({ return useMutation<void, APIError, CreateMailboxParams>({
mutationFn: createMailbox, mutationFn: createMailbox,
onSuccess: (data, variables, onMutateResult, context) => { ...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [ queryKey: [
KEY_LIST_MAILBOX, KEY_LIST_MAILBOX,
@@ -54,12 +55,26 @@ export const useCreateMailbox = (options: UseCreateMailboxParams) => {
], ],
}); });
if (options?.onSuccess) { if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: CreateMailboxParams,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (options?.onError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: CreateMailboxParams,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });

View File

@@ -49,7 +49,8 @@ export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
return useMutation<void, APIError, UpdateMailboxParams>({ return useMutation<void, APIError, UpdateMailboxParams>({
mutationFn: (data) => mutationFn: (data) =>
updateMailbox({ ...data, mailboxId: options.mailboxId }), updateMailbox({ ...data, mailboxId: options.mailboxId }),
onSuccess: (data, variables, onMutateResult, context) => { ...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [ queryKey: [
KEY_LIST_MAILBOX, KEY_LIST_MAILBOX,
@@ -57,12 +58,26 @@ export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
], ],
}); });
if (options?.onSuccess) { if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: UpdateMailboxParams,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (options?.onError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: UpdateMailboxParams,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });

View File

@@ -41,7 +41,7 @@ export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
return useMutation<void, APIError, DeleteTeamAccessProps>({ return useMutation<void, APIError, DeleteTeamAccessProps>({
mutationFn: deleteTeamAccess, mutationFn: deleteTeamAccess,
...options, ...options,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM_ACCESSES], queryKey: [KEY_LIST_TEAM_ACCESSES],
}); });
@@ -52,12 +52,26 @@ export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
queryKey: [KEY_LIST_TEAM], queryKey: [KEY_LIST_TEAM],
}); });
if (options?.onSuccess) { if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: DeleteTeamAccessProps,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (options?.onError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: DeleteTeamAccessProps,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });

View File

@@ -49,7 +49,7 @@ export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
return useMutation<Access, APIError, UpdateTeamAccessProps>({ return useMutation<Access, APIError, UpdateTeamAccessProps>({
mutationFn: updateTeamAccess, mutationFn: updateTeamAccess,
...options, ...options,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM_ACCESSES], queryKey: [KEY_LIST_TEAM_ACCESSES],
}); });
@@ -57,12 +57,26 @@ export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
queryKey: [KEY_TEAM], queryKey: [KEY_TEAM],
}); });
if (options?.onSuccess) { if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: Access,
variables: UpdateTeamAccessProps,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (options?.onError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: UpdateTeamAccessProps,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });

View File

@@ -34,17 +34,31 @@ export const useRemoveTeam = (options?: UseRemoveTeamOptions) => {
return useMutation<void, APIError, RemoveTeamProps>({ return useMutation<void, APIError, RemoveTeamProps>({
mutationFn: removeTeam, mutationFn: removeTeam,
...options, ...options,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM], queryKey: [KEY_LIST_TEAM],
}); });
if (options?.onSuccess) { if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: RemoveTeamProps,
context: unknown,
) => void
)(data, variables, context);
} }
}, },
onError: (error, variables, onMutateResult, context) => { onError: (error, variables, context) => {
if (options?.onError) { if (options?.onError) {
options.onError(error, variables, onMutateResult, context); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: RemoveTeamProps,
context: unknown,
) => void
)(error, variables, context);
} }
}, },
}); });