(frontend) add new access role to domain

add new access role to domain first commit
This commit is contained in:
Eléonore Voisin
2025-02-10 15:23:39 +01:00
committed by Sabrina Demagny
parent ea1f06f6cc
commit 67d9b6462f
12 changed files with 589 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ and this project adheres to
### Added
- ✨(frontend) feature modal add new access role to domain
- ✨(api) allow invitations for domain management #708
## [1.13.1] - 2025-03-04

View File

@@ -156,6 +156,10 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
bin/pylint --diff-only=origin/main
.PHONY: lint-pylint
lint-front:
cd $(PATH_FRONT) && yarn lint
.PHONY: lint-front
test: ## run project tests
@$(MAKE) test-back-parallel
.PHONY: test

View File

@@ -327,6 +327,10 @@ input:-webkit-autofill:focus {
outline: var(--c--theme--colors--primary-600) solid 2px;
}
.c__radio input::before {
box-shadow: inset 1em 1em var(--c--theme--colors--primary-600);
}
/**
* Button
*/

View File

@@ -1,3 +1,5 @@
export * from './useMailDomainAccesses';
export * from './useUpdateMailDomainAccess';
export * from './useCreateMailDomainAccess';
export * from './useDeleteMailDomainAccess';
export * from './useCreateInvitation';

View File

@@ -0,0 +1,48 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Invitation, OptionType } from '@/features/teams/member-add/types';
import { MailDomain, Role } from '../../domains';
interface CreateInvitationParams {
email: User['email'];
role: Role;
mailDomainSlug: MailDomain['slug'];
}
export const createInvitation = async ({
email,
role,
mailDomainSlug,
}: CreateInvitationParams): Promise<Invitation> => {
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/invitations/`,
{
method: 'POST',
body: JSON.stringify({
email,
role,
}),
},
);
if (!response.ok) {
throw new APIError(
`Failed to create the invitation for ${email}`,
await errorCauses(response, {
value: email,
type: OptionType.INVITATION,
}),
);
}
return response.json() as Promise<Invitation>;
};
export function useCreateInvitation() {
return useMutation<Invitation, APIError, CreateInvitationParams>({
mutationFn: createInvitation,
});
}

View File

@@ -0,0 +1,62 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_MAIL_DOMAIN, Role } from '@/features/mail-domains/domains';
import { Access } from '../types';
import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses';
interface CreateMailDomainAccessProps {
slug: string;
user: string;
role: Role;
}
export const createMailDomainAccess = async ({
slug,
user,
role,
}: CreateMailDomainAccessProps): Promise<Access> => {
const response = await fetchAPI(`mail-domains/${slug}/accesses/`, {
method: 'POST',
body: JSON.stringify({ user, role }),
});
if (!response.ok) {
throw new APIError('Failed to create role', await errorCauses(response));
}
return response.json() as Promise<Access>;
};
type UseCreateMailDomainAccessOptions = UseMutationOptions<
Access,
APIError,
CreateMailDomainAccessProps
>;
export const useCreateMailDomainAccess = (
options?: UseCreateMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
return useMutation<Access, APIError, CreateMailDomainAccessProps>({
mutationFn: createMailDomainAccess,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
});
void queryClient.invalidateQueries({ queryKey: [KEY_MAIL_DOMAIN] });
options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context);
},
});
};

View File

@@ -0,0 +1,41 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { MailDomain } from '@/features/mail-domains/domains/types';
export type UsersParams = {
query: string;
mailDomain: MailDomain['slug'];
};
type UsersResponse = User[];
export const getUsers = async ({
query,
mailDomain,
}: UsersParams): Promise<UsersResponse> => {
const response = await fetchAPI(
`mail-domains/${mailDomain}/accesses/users/?q=${query}`,
);
if (!response.ok) {
throw new APIError('Failed to get the users', await errorCauses(response));
}
const res = (await response.json()) as User[];
return res;
};
export const KEY_LIST_USER = 'users';
export function useUsers(
param: UsersParams,
queryConfig?: UseQueryOptions<UsersResponse, APIError, UsersResponse>,
) {
return useQuery<UsersResponse, APIError, UsersResponse>({
queryKey: [KEY_LIST_USER, param],
queryFn: () => getUsers(param),
...queryConfig,
});
}

View File

@@ -1,17 +1,56 @@
import React from 'react';
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { AccessesGrid } from '@/features/mail-domains/access-management/components/AccessesGrid';
import { MailDomain, Role } from '../../domains';
import { ModalCreateAccess } from './ModalCreateAccess';
export const AccessesContent = ({
mailDomain,
currentRole,
}: {
mailDomain: MailDomain;
currentRole: Role;
}) => (
}) => {
const { t } = useTranslation();
const [isModalAccessOpen, setIsModalAccessOpen] = useState(false);
return (
<>
<Box
$direction="row"
$justify="flex-end"
$margin={{ bottom: 'small' }}
$align="center"
>
<Box $display="flex" $direction="row">
{mailDomain?.abilities.post && (
<Button
aria-label={t('Add a new access in {{name}} domain', {
name: mailDomain?.name,
})}
onClick={() => {
setIsModalAccessOpen(true);
}}
>
{t('Add a new access')}
</Button>
)}
</Box>
</Box>
<AccessesGrid mailDomain={mailDomain} currentRole={currentRole} />
{isModalAccessOpen && mailDomain && (
<ModalCreateAccess
mailDomain={mailDomain}
currentRole={currentRole}
onClose={() => setIsModalAccessOpen(false)}
/>
)}
</>
);
};

View File

@@ -0,0 +1,165 @@
import {
Button,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import { useCreateMailDomainAccess } from '@/features/mail-domains/access-management';
import {
OptionSelect,
OptionType,
isOptionNewMember,
} from '@/features/teams/member-add/types';
import { MailDomain, Role } from '../../domains';
import { useCreateInvitation } from '../api';
import { ChooseRole } from './ChooseRole';
import { OptionsSelect, SearchMembers } from './SearchMembers';
interface ModalCreateAccessProps {
mailDomain: MailDomain;
currentRole: Role;
onClose: () => void;
}
type APIErrorMember = APIError<{
value: string;
type: OptionType;
}>;
export const ModalCreateAccess = ({
mailDomain,
currentRole,
onClose,
}: ModalCreateAccessProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [selectedMembers, setSelectedMembers] = useState<OptionsSelect>([]);
const [role, setRole] = useState<Role>(Role.VIEWER);
const createInvitation = useCreateInvitation();
const { mutateAsync: createMailDomainAccess } = useCreateMailDomainAccess();
const onSuccess = (option: OptionSelect) => {
const message = !isOptionNewMember(option)
? t('Invitation sent to {{email}}', {
email: option.value.email,
})
: t('Access added to {{name}}', {
name: option.value.name,
});
toast(message, VariantType.SUCCESS);
};
const onError = (dataError: APIErrorMember['data']) => {
const messageError =
dataError?.type === OptionType.INVITATION
? t('Failed to create the invitation')
: t('Failed to add access');
toast(messageError, VariantType.ERROR);
};
const switchActions = (selectedMembers: OptionsSelect) =>
selectedMembers.map(async (selectedMember) => {
switch (selectedMember.type) {
case OptionType.INVITATION:
await createInvitation.mutateAsync({
email: selectedMember.value.email,
mailDomainSlug: mailDomain.slug,
role,
});
break;
default:
await createMailDomainAccess({
slug: mailDomain.slug,
user: selectedMember.value.id,
role,
});
break;
}
return selectedMember;
});
const handleValidate = async () => {
const settledPromises = await Promise.allSettled(
switchActions(selectedMembers),
);
settledPromises.forEach((settledPromise) => {
switch (settledPromise.status) {
case 'rejected':
onError((settledPromise.reason as APIErrorMember).data);
break;
case 'fulfilled':
onSuccess(settledPromise.value);
break;
}
onClose();
});
};
return (
<Modal
isOpen
leftActions={
<Button color="secondary" fullWidth onClick={onClose}>
{t('Cancel')}
</Button>
}
onClose={onClose}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
disabled={!selectedMembers.length}
onClick={() => void handleValidate()}
>
{t('Add to domain')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<Text $size="h3" $margin="none">
{t('Add a new access')}
</Text>
</Box>
}
>
<Box $margin={{ bottom: 'xl', top: 'large' }}>
<SearchMembers
mailDomain={mailDomain}
setSelectedMembers={setSelectedMembers}
selectedMembers={selectedMembers}
/>
{selectedMembers.length > 0 && (
<Box $margin={{ top: 'small' }}>
<Text as="h4" $textAlign="left" $margin={{ bottom: 'tiny' }}>
{t('Choose a role')}
</Text>
<ChooseRole
roleAccess={currentRole}
disabled={false}
availableRoles={[Role.VIEWER, Role.ADMIN, Role.OWNER]}
currentRole={currentRole}
setRole={setRole}
/>
</Box>
)}
</Box>
</Modal>
);
};

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { MailDomain } from '@/features/mail-domains/domains/types';
import { OptionSelect, OptionType } from '@/features/teams/member-add/types';
import { isValidEmail } from '@/utils';
import { useUsers } from '../api/useUsers';
export type OptionsSelect = Options<OptionSelect>;
interface SearchMembersProps {
mailDomain: MailDomain;
selectedMembers: OptionsSelect;
setSelectedMembers: (value: OptionsSelect) => void;
disabled?: boolean;
}
export const SearchMembers = ({
mailDomain,
selectedMembers,
setSelectedMembers,
disabled,
}: SearchMembersProps) => {
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
const resolveOptionsRef = useRef<((value: OptionsSelect) => void) | null>(
null,
);
const { data } = useUsers({
query: userQuery,
mailDomain: mailDomain.slug,
});
const options = data;
useEffect(() => {
if (!resolveOptionsRef.current || !options) {
return;
}
const optionsFiltered = options.filter(
(user) =>
!selectedMembers?.find(
(selectedUser) => selectedUser.value.email === user.email,
),
);
let users: OptionsSelect = optionsFiltered.map((user) => ({
value: user,
label: user.name || user.email,
type: OptionType.NEW_MEMBER,
}));
if (userQuery && isValidEmail(userQuery)) {
const isFoundUser = !!optionsFiltered.find(
(user) => user.email === userQuery,
);
const isFoundEmail = !!selectedMembers.find(
(selectedMember) => selectedMember.value.email === userQuery,
);
if (!isFoundUser && !isFoundEmail) {
users = [
{
value: { email: userQuery },
label: userQuery,
type: OptionType.INVITATION,
},
];
}
}
resolveOptionsRef.current(users);
resolveOptionsRef.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, selectedMembers]);
const loadOptions = (): Promise<OptionsSelect> => {
return new Promise<OptionsSelect>((resolve) => {
resolveOptionsRef.current = resolve;
});
};
const timeout = useRef<NodeJS.Timeout | null>(null);
const onInputChangeHandle = useCallback((newValue: string) => {
setInput(newValue);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setUserQuery(newValue);
}, 1000);
}, []);
return (
<AsyncSelect
isDisabled={disabled}
aria-label={t('Find a member to add to the domain')}
isMulti
loadOptions={loadOptions}
defaultOptions={[]}
onInputChange={onInputChangeHandle}
inputValue={input}
placeholder={t(
'Search for members to assign them a role (name or email)',
{},
)}
noOptionsMessage={() =>
t('Invite new members with roles', { name: mailDomain.name })
}
onChange={(value) => {
setInput('');
setUserQuery('');
setSelectedMembers(value);
}}
/>
);
};

View File

@@ -0,0 +1,87 @@
import { useToastProvider } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import { useCreateMailDomainAccess } from '@/features/mail-domains/access-management';
import { AppWrapper } from '@/tests/utils';
import { MailDomain, Role } from '../../../domains';
import { ModalCreateAccess } from '../ModalCreateAccess';
const domain: MailDomain = {
id: '897-9879-986789-89798-897',
name: 'Domain test',
created_at: '121212',
updated_at: '121212',
slug: 'test-domain',
status: 'pending',
support_email: 'sfs@test-domain.fr',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
};
jest.mock('@openfun/cunningham-react', () => ({
...jest.requireActual('@openfun/cunningham-react'),
useToastProvider: jest.fn(),
}));
jest.mock('../../api', () => ({
useCreateInvitation: jest.fn(() => ({ mutateAsync: jest.fn() })),
}));
jest.mock('@/features/mail-domains/access-management', () => ({
useCreateMailDomainAccess: jest.fn(),
}));
describe('ModalCreateAccess', () => {
const mockOnClose = jest.fn();
const mockToast = jest.fn();
const mockCreateMailDomainAccess = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
(useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast });
(useCreateMailDomainAccess as jest.Mock).mockReturnValue({
mutateAsync: mockCreateMailDomainAccess,
});
});
const renderModalCreateAccess = () => {
return render(
<ModalCreateAccess
mailDomain={domain}
currentRole={Role.ADMIN}
onClose={mockOnClose}
/>,
{ wrapper: AppWrapper },
);
};
it('renders the modal with all elements', () => {
renderModalCreateAccess();
expect(screen.getByText('Add a new access')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Add to domain/i }),
).toBeInTheDocument();
});
it('calls onClose when Cancel is clicked', async () => {
renderModalCreateAccess();
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
await userEvent.click(cancelButton);
await waitFor(() => expect(mockOnClose).toHaveBeenCalledTimes(1), {
timeout: 3000,
});
});
});

View File

@@ -22,6 +22,7 @@
"0 group to display.": "0 groupe à afficher.",
"Access icon": "Icône d'accès",
"Access management": "Gestion des rôles",
"Access added to {{name}}": "Accès accordés à {{name}}",
"Accesses list card": "Carte de la liste des accès",
"Accessibility statement": "Déclaration d'accessibilité",
"Accessibility: non-compliant": "Accessibilité : non conforme",
@@ -29,11 +30,13 @@
"Actions required detail": "Détail des actions requises",
"Add a mail domain": "Ajouter un nom de domaine",
"Add a member": "Ajouter un membre",
"Add a new access": "Ajouter un nouveau rôle",
"Add a team": "Ajouter un groupe",
"Add members to the team": "Ajouter des membres à l'équipe",
"Add the domain": "Ajouter le domaine",
"Add the following DNS values:": "Ajouter les valeurs DNS suivantes :",
"Add to group": "Ajouter au groupe",
"Add to domain": "Ajouter au domaine",
"Address: National Agency for Territorial Cohesion - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07 Paris": "Adresse : Agence Nationale de la Cohésion des Territoires - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07",
"Administration": "Administration",
"Administrator": "Administrateur",
@@ -85,6 +88,7 @@
"Enable mailbox": "Activer la boîte mail",
"Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné",
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr",
"Failed to add access": "Impossible d'ajouter les accès",
"Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe",
"Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}",
"Failed to fetch domain data": "Impossible de récupérer les données du domaine",
@@ -104,7 +108,8 @@
"Image 404 page not found": "Image 404 page introuvable",
"Improvement and contact": "Amélioration et contact",
"Invitation sent to {{email}}": "Invitation envoyée à {{email}}",
"Invite new members to {{teamName}}": "Invitez de nouveaux membres à rejoindre {{teamName}}",
"Invite new members to {{teamName}}": "Inviter de nouveaux membres à rejoindre {{teamName}}",
"Invite new members with roles": "Inviter de nouveaux membres avec un rôle",
"It must not contain spaces, accents or special characters (except \".\" or \"-\"). E.g.: jean.dupont": "Il ne doit pas contenir d'espaces, d'accents ou de caractères spéciaux (excepté \".\" ou \"-\"). Ex. : jean.dupont",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.",
"It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "C'est vrai, vous n'avez pas à cliquer sur un bloc qui couvre la moitié de la page pour dire que vous acceptez le placement de cookies — même si vous ne savez pas ce que cela signifie !",
@@ -168,6 +173,7 @@
"Roles": "Rôles",
"Régie": "Régie",
"Search new members (name or email)": "Rechercher de nouveaux membres (nom ou email)",
"Search for members to assign them a role (name or email)": "Rechercher des membres afin de leur attribuer un rôle (nom ou email)",
"Secondary email address": "Adresse e-mail secondaire",
"Send a letter by post (free of charge, no stamp needed):": "Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre) :",
"Something bad happens, please refresh the page.": "Une erreur inattendue s'est produite, rechargez la page.",