(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 ### Added
- ✨(frontend) feature modal add new access role to domain
- ✨(api) allow invitations for domain management #708 - ✨(api) allow invitations for domain management #708
## [1.13.1] - 2025-03-04 ## [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 bin/pylint --diff-only=origin/main
.PHONY: lint-pylint .PHONY: lint-pylint
lint-front:
cd $(PATH_FRONT) && yarn lint
.PHONY: lint-front
test: ## run project tests test: ## run project tests
@$(MAKE) test-back-parallel @$(MAKE) test-back-parallel
.PHONY: test .PHONY: test

View File

@@ -327,6 +327,10 @@ input:-webkit-autofill:focus {
outline: var(--c--theme--colors--primary-600) solid 2px; 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 * Button
*/ */

View File

@@ -1,3 +1,5 @@
export * from './useMailDomainAccesses'; export * from './useMailDomainAccesses';
export * from './useUpdateMailDomainAccess'; export * from './useUpdateMailDomainAccess';
export * from './useCreateMailDomainAccess';
export * from './useDeleteMailDomainAccess'; 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 { AccessesGrid } from '@/features/mail-domains/access-management/components/AccessesGrid';
import { MailDomain, Role } from '../../domains'; import { MailDomain, Role } from '../../domains';
import { ModalCreateAccess } from './ModalCreateAccess';
export const AccessesContent = ({ export const AccessesContent = ({
mailDomain, mailDomain,
currentRole, currentRole,
}: { }: {
mailDomain: MailDomain; mailDomain: MailDomain;
currentRole: Role; currentRole: Role;
}) => ( }) => {
<> const { t } = useTranslation();
<AccessesGrid mailDomain={mailDomain} currentRole={currentRole} />
</> 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.", "0 group to display.": "0 groupe à afficher.",
"Access icon": "Icône d'accès", "Access icon": "Icône d'accès",
"Access management": "Gestion des rôles", "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", "Accesses list card": "Carte de la liste des accès",
"Accessibility statement": "Déclaration d'accessibilité", "Accessibility statement": "Déclaration d'accessibilité",
"Accessibility: non-compliant": "Accessibilité : non conforme", "Accessibility: non-compliant": "Accessibilité : non conforme",
@@ -29,11 +30,13 @@
"Actions required detail": "Détail des actions requises", "Actions required detail": "Détail des actions requises",
"Add a mail domain": "Ajouter un nom de domaine", "Add a mail domain": "Ajouter un nom de domaine",
"Add a member": "Ajouter un membre", "Add a member": "Ajouter un membre",
"Add a new access": "Ajouter un nouveau rôle",
"Add a team": "Ajouter un groupe", "Add a team": "Ajouter un groupe",
"Add members to the team": "Ajouter des membres à l'équipe", "Add members to the team": "Ajouter des membres à l'équipe",
"Add the domain": "Ajouter le domaine", "Add the domain": "Ajouter le domaine",
"Add the following DNS values:": "Ajouter les valeurs DNS suivantes :", "Add the following DNS values:": "Ajouter les valeurs DNS suivantes :",
"Add to group": "Ajouter au groupe", "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", "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", "Administration": "Administration",
"Administrator": "Administrateur", "Administrator": "Administrateur",
@@ -85,6 +88,7 @@
"Enable mailbox": "Activer la boîte mail", "Enable mailbox": "Activer la boîte mail",
"Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné", "Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné",
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr", "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 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 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", "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", "Image 404 page not found": "Image 404 page introuvable",
"Improvement and contact": "Amélioration et contact", "Improvement and contact": "Amélioration et contact",
"Invitation sent to {{email}}": "Invitation envoyée à {{email}}", "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 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 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 !", "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", "Roles": "Rôles",
"Régie": "Régie", "Régie": "Régie",
"Search new members (name or email)": "Rechercher de nouveaux membres (nom ou email)", "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", "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) :", "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.", "Something bad happens, please refresh the page.": "Une erreur inattendue s'est produite, rechargez la page.",