✨(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:
@@ -9,6 +9,7 @@ and this project adheres to
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- ✨(front) create, manage & delete aliases
|
||||
- ✨(domains) alias sorting and admin
|
||||
- ✨(aliases) delete all aliases in one call #1002
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
|
||||
@@ -19,13 +19,17 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
|
||||
>
|
||||
{label} {required && '*'}
|
||||
</label>
|
||||
{error && (
|
||||
<Text $size="xs" $theme="danger" $variation="600">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<input
|
||||
id={label}
|
||||
aria-required={required}
|
||||
required={required}
|
||||
style={{
|
||||
padding: '12px',
|
||||
margin: '6px 0',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
|
||||
@@ -34,11 +38,6 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<Text $size="xs" $color="error-500">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './Tag';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
export * from './separators';
|
||||
export * from './tabs/CustomTabs';
|
||||
|
||||
@@ -27,7 +27,7 @@ export const CustomTabs = ({ tabs }: Props) => {
|
||||
const id = tab.id ?? tab.label;
|
||||
return (
|
||||
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
|
||||
<Box $direction="row" $align="center" $gap="5px">
|
||||
<Box $direction="row" $gap="5px">
|
||||
{tab.iconName && (
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
{tab.iconName}
|
||||
|
||||
@@ -1,63 +1,69 @@
|
||||
.customTabsContainer {
|
||||
:global {
|
||||
.react-aria-TabList {
|
||||
display: flex;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 30px;
|
||||
|
||||
&[data-orientation='horizontal'] {
|
||||
.react-aria-Tab {
|
||||
border-bottom: 2px solid var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
}
|
||||
:global(.react-aria-Tabs) {
|
||||
width: 100%;
|
||||
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 {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
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-selected] {
|
||||
font-weight: 500;
|
||||
color: var(--c--theme--colors--primary-text);
|
||||
border-bottom: 2px solid var(--c--theme--colors--primary-text);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--text-color-disabled);
|
||||
&[data-selected] {
|
||||
border-bottom: 2px solid var(--c--theme--colors--primary-600) !important;
|
||||
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);
|
||||
--border-color: var(--text-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.react-aria-TabPanel {
|
||||
margin-top: 4px;
|
||||
padding: 10px;
|
||||
&[data-focus-visible]:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
border: 2px solid var(--focus-ring-color);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--c--theme--colors--primary-600);
|
||||
}
|
||||
:global(.react-aria-TabPanel) {
|
||||
margin-top: 15px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--focus-ring-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,7 @@ describe('useDeleteMailDomainAccess', () => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
{ slug: 'example-slug', accessId: '1-1-1-1-1' },
|
||||
undefined,
|
||||
{ client: {}, meta: undefined, mutationKey: undefined },
|
||||
undefined, // context
|
||||
),
|
||||
);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
|
||||
@@ -104,8 +104,7 @@ describe('useUpdateMailDomainAccess', () => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
mockResponse, // data
|
||||
{ slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables
|
||||
undefined, // onMutateResult
|
||||
{ client: {}, meta: undefined, mutationKey: undefined }, // context
|
||||
undefined, // context
|
||||
),
|
||||
);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
|
||||
@@ -44,19 +44,42 @@ export const useCreateMailDomainAccess = (
|
||||
options?: UseCreateMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
|
||||
return useMutation<Access, APIError, CreateMailDomainAccessProps>({
|
||||
mutationFn: createMailDomainAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
});
|
||||
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) => {
|
||||
options?.onError?.(error, variables, onMutateResult, 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: CreateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -46,10 +46,15 @@ export const useDeleteMailDomainAccess = (
|
||||
options?: UseDeleteMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
return useMutation<void, APIError, DeleteMailDomainAccessProps>({
|
||||
mutationFn: deleteMailDomainAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
});
|
||||
@@ -59,13 +64,27 @@ export const useDeleteMailDomainAccess = (
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
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: void,
|
||||
variables: DeleteMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, onMutateResult, 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: DeleteMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,23 +51,42 @@ export const useUpdateMailDomainAccess = (
|
||||
options?: UseUpdateMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
|
||||
mutationFn: updateMailDomainAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_MAIL_DOMAIN],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
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: UpdateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, onMutateResult, 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: UpdateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './useAliases';
|
||||
export * from './useAliasesInfinite';
|
||||
export * from './useCreateAlias';
|
||||
export * from './useDeleteAlias';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './AliasesView';
|
||||
export * from './ModalCreateAlias';
|
||||
export * from './ModalEditAlias';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AliasesListView';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Tag, Text } from '@/components';
|
||||
import { Box, CustomTabs, Tag, Text } from '@/components';
|
||||
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 {
|
||||
MailDomain,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
Role,
|
||||
} from '@/features/mail-domains/domains';
|
||||
import { MailBoxesView } from '@/features/mail-domains/mailboxes';
|
||||
import { useMailboxes } from '@/features/mail-domains/mailboxes/api/useMailboxes';
|
||||
|
||||
type Props = {
|
||||
mailDomain: MailDomain;
|
||||
@@ -30,6 +33,18 @@ export const MailDomainView = ({
|
||||
const router = useRouter();
|
||||
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 = () => {
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -110,15 +125,27 @@ export const MailDomainView = ({
|
||||
$padding={{ horizontal: 'md' }}
|
||||
$margin={{ top: 'md' }}
|
||||
$background="white"
|
||||
$align="center"
|
||||
$gap="8px"
|
||||
$radius="4px"
|
||||
$direction="row"
|
||||
$css={`
|
||||
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>
|
||||
</>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ModalAddMailDomain = ({
|
||||
|
||||
const addMailDomainValidationSchema = z.object({
|
||||
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 }>({
|
||||
|
||||
@@ -46,7 +46,8 @@ export const useCreateMailbox = (options: UseCreateMailboxParams) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, CreateMailboxParams>({
|
||||
mutationFn: createMailbox,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
KEY_LIST_MAILBOX,
|
||||
@@ -54,12 +55,26 @@ export const useCreateMailbox = (options: UseCreateMailboxParams) => {
|
||||
],
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,8 @@ export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
|
||||
return useMutation<void, APIError, UpdateMailboxParams>({
|
||||
mutationFn: (data) =>
|
||||
updateMailbox({ ...data, mailboxId: options.mailboxId }),
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
KEY_LIST_MAILBOX,
|
||||
@@ -57,12 +58,26 @@ export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
|
||||
],
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
|
||||
return useMutation<void, APIError, DeleteTeamAccessProps>({
|
||||
mutationFn: deleteTeamAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES],
|
||||
});
|
||||
@@ -52,12 +52,26 @@ export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
|
||||
return useMutation<Access, APIError, UpdateTeamAccessProps>({
|
||||
mutationFn: updateTeamAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES],
|
||||
});
|
||||
@@ -57,12 +57,26 @@ export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
|
||||
queryKey: [KEY_TEAM],
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,17 +34,31 @@ export const useRemoveTeam = (options?: UseRemoveTeamOptions) => {
|
||||
return useMutation<void, APIError, RemoveTeamProps>({
|
||||
mutationFn: removeTeam,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user