(frontend) add or invite users to a doc

Add or invite a user to a doc.
If the user is already in the system,
the user will be added to the doc,
otherwise, the user will be invited to the doc.
This commit is contained in:
Anthony LC
2024-05-30 10:08:49 +02:00
committed by Anthony LC
parent d30ac8ee4e
commit 2b67d0bd26
19 changed files with 823 additions and 27 deletions

View File

@@ -15,6 +15,8 @@ and this project adheres to
- Remove document (#68) - Remove document (#68)
- (docker) dockerize dev frontend (#63) - (docker) dockerize dev frontend (#63)
- (backend) list users with email filtering (#79) - (backend) list users with email filtering (#79)
- (frontend) add user to a document (#52)
- (frontend) invite user to a document (#52)
## Changed ## Changed

View File

@@ -0,0 +1,199 @@
import { expect, test } from '@playwright/test';
import { createPad, keyCloakSignIn, randomName } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Document add users', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createPad(page, 'select-multi-users', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add a user' }).click();
const inputSearch = page.getByLabel(/Find a user to add to the document/);
await expect(inputSearch).toBeVisible();
// Select user 1
await inputSearch.fill('user');
const response = await responsePromise;
const users = (await response.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: users[0].email }).click();
// Select user 2
await inputSearch.fill('user');
await page.getByRole('option', { name: users[1].email }).click();
// Select email
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Check user 1 tag
await expect(
page.getByText(`${users[0].email}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[0].email}`)).toBeVisible();
// Check user 2 tag
await expect(
page.getByText(`${users[1].email}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[1].email}`)).toBeVisible();
// Check invitation tag
await expect(page.getByText(email, { exact: true })).toBeVisible();
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
// Check roles are displayed
await expect(page.getByText(/Choose a role/)).toBeVisible();
await expect(page.getByRole('radio', { name: 'Reader' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('radio', { name: 'Administrator' }),
).toBeVisible();
});
test('it sends a new invitation and adds a new user', async ({
page,
browserName,
}) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createPad(page, 'user-invitation', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add a user' }).click();
const inputSearch = page.getByLabel(/Find a user to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Select a new user
await inputSearch.fill('user');
const responseSearchUser = await responsePromiseSearchUser;
const users = (await responseSearchUser.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: users[0].email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
const responsePromiseAddUser = page.waitForResponse(
(response) =>
response.url().includes('/accesses/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
// Check user added
await expect(page.getByText(`User added to the document.`)).toBeVisible();
const responseAddUser = await responsePromiseAddUser;
expect(responseAddUser.ok()).toBeTruthy();
});
test('it try to add twice the same user', async ({ page, browserName }) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createPad(page, 'user-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add a user' }).click();
const inputSearch = page.getByLabel(/Find a user to add to the document/);
await inputSearch.fill('user');
const responseSearchUser = await responsePromiseSearchUser;
const users = (await responseSearchUser.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: users[0].email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
const responsePromiseAddMember = page.waitForResponse(
(response) =>
response.url().includes('/accesses/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(page.getByText(`User added to the document.`)).toBeVisible();
const responseAddMember = await responsePromiseAddMember;
expect(responseAddMember.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add a user' }).click();
await inputSearch.fill('user');
await expect(
page.getByRole('option', { name: users[0].email }),
).toBeHidden();
});
test('it try to add twice the same invitation', async ({
page,
browserName,
}) => {
await createPad(page, 'invitation-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add a user' }).click();
const inputSearch = page.getByLabel(/Find a user to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add a user' }).click();
await inputSearch.fill(email);
await expect(page.getByRole('option', { name: email })).toBeHidden();
});
});

View File

@@ -0,0 +1,3 @@
export * from './useCreateDocInvitation';
export * from './useCreateDocAccess';
export * from './useUsers';

View File

@@ -0,0 +1,55 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Access, KEY_LIST_PAD, Pad, Role } from '../../pad-management';
import { OptionType } from '../types';
import { KEY_LIST_USER } from './useUsers';
interface CreateDocAccessParams {
role: Role;
docId: Pad['id'];
userId: User['id'];
}
export const createDocAccess = async ({
userId,
role,
docId,
}: CreateDocAccessParams): Promise<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/`, {
method: 'POST',
body: JSON.stringify({
user: userId,
role,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to add the user in the doc.`,
await errorCauses(response, {
type: OptionType.NEW_USER,
}),
);
}
return response.json() as Promise<Access>;
};
export function useCreateDocAccess() {
const queryClient = useQueryClient();
return useMutation<Access, APIError, CreateDocAccessParams>({
mutationFn: createDocAccess,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PAD],
});
void queryClient.resetQueries({
queryKey: [KEY_LIST_USER],
});
},
});
}

View File

@@ -0,0 +1,45 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Pad, Role } from '@/features/pads/pad-management';
import { DocInvitation, OptionType } from '../types';
interface CreateDocInvitationParams {
email: User['email'];
role: Role;
docId: Pad['id'];
}
export const createDocInvitation = async ({
email,
role,
docId,
}: CreateDocInvitationParams): Promise<DocInvitation> => {
const response = await fetchAPI(`documents/${docId}/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<DocInvitation>;
};
export function useCreateInvitation() {
return useMutation<DocInvitation, APIError, CreateDocInvitationParams>({
mutationFn: createDocInvitation,
});
}

View File

@@ -0,0 +1,43 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Pad } from '@/features/pads/pad-management';
export type UsersParams = {
query: string;
docId: Pad['id'];
};
type UsersResponse = APIList<User>;
export const getUsers = async ({
query,
docId,
}: UsersParams): Promise<UsersResponse> => {
const queriesParams = [];
queriesParams.push(query ? `q=${query}` : '');
queriesParams.push(docId ? `document_id=${docId}` : '');
const queryParams = queriesParams.filter(Boolean).join('&');
const response = await fetchAPI(`users/?${queryParams}`);
if (!response.ok) {
throw new APIError('Failed to get the users', await errorCauses(response));
}
return response.json() as Promise<UsersResponse>;
};
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

@@ -0,0 +1,14 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.34 26.04C20.9 26.02 20.46 26 20 26C15.16 26 10.64 27.34 6.78 29.64C5.02 30.68 4 32.64 4 34.7V40H22.52C20.94 37.74 20 34.98 20 32C20 29.86 20.5 27.86 21.34 26.04Z"
fill="currentColor"
/>
<path
d="M20 24C24.4183 24 28 20.4183 28 16C28 11.5817 24.4183 8 20 8C15.5817 8 12 11.5817 12 16C12 20.4183 15.5817 24 20 24Z"
fill="currentColor"
/>
<path
d="M33 24C28.032 24 24 28.032 24 33C24 37.968 28.032 42 33 42C37.968 42 42 37.968 42 33C42 28.032 37.968 24 33 24ZM36.6 33.9H33.9V36.6C33.9 37.095 33.495 37.5 33 37.5C32.505 37.5 32.1 37.095 32.1 36.6V33.9H29.4C28.905 33.9 28.5 33.495 28.5 33C28.5 32.505 28.905 32.1 29.4 32.1H32.1V29.4C32.1 28.905 32.505 28.5 33 28.5C33.495 28.5 33.9 28.905 33.9 29.4V32.1H36.6C37.095 32.1 37.5 32.505 37.5 33C37.5 33.495 37.095 33.9 36.6 33.9Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 925 B

View File

@@ -0,0 +1,45 @@
import { Radio, RadioGroup } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Role } from '@/features/pads/pad-management';
interface ChooseRoleProps {
currentRole: Role;
disabled: boolean;
defaultRole: Role;
setRole: (role: Role) => void;
}
export const ChooseRole = ({
defaultRole,
disabled,
currentRole,
setRole,
}: ChooseRoleProps) => {
const { t } = useTranslation();
const translatedRoles = {
[Role.ADMIN]: t('Administrator'),
[Role.READER]: t('Reader'),
[Role.OWNER]: t('Owner'),
[Role.EDITOR]: t('Editor'),
};
return (
<RadioGroup>
{Object.values(Role).map((role) => (
<Radio
key={role}
label={translatedRoles[role]}
value={role}
name="role"
onChange={(evt) => setRole(evt.target.value as Role)}
defaultChecked={defaultRole === role}
disabled={
disabled || (currentRole !== Role.OWNER && role === Role.OWNER)
}
/>
))}
</RadioGroup>
);
};

View File

@@ -0,0 +1,193 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Pad, Role } from '@/features/pads/pad-management';
import { useCreateDocAccess, useCreateInvitation } from '../api';
import IconAddUser from '../assets/add-user.svg';
import {
OptionInvitation,
OptionNewUser,
OptionSelect,
OptionType,
isOptionNewUser,
} from '../types';
import { ChooseRole } from './ChooseRole';
import { OptionsSelect, SearchUsers } from './SearchUsers';
const GlobalStyle = createGlobalStyle`
.c__modal {
overflow: visible;
}
`;
type APIErrorUser = APIError<{
value: string;
type: OptionType;
}>;
interface ModalAddUsersProps {
currentRole: Role;
onClose: () => void;
doc: Pad;
}
export const ModalAddUsers = ({
currentRole,
onClose,
doc,
}: ModalAddUsersProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const [selectedUsers, setSelectedUsers] = useState<OptionsSelect>([]);
const [selectedRole, setSelectedRole] = useState<Role>(Role.READER);
const { toast } = useToastProvider();
const { mutateAsync: createInvitation } = useCreateInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
const [isPending, setIsPending] = useState<boolean>(false);
const switchActions = (selectedUsers: OptionsSelect) =>
selectedUsers.map(async (selectedUser) => {
switch (selectedUser.type) {
case OptionType.INVITATION:
await createInvitation({
email: selectedUser.value.email,
role: selectedRole,
docId: doc.id,
});
break;
case OptionType.NEW_USER:
await createDocAccess({
role: selectedRole,
docId: doc.id,
userId: selectedUser.value.id,
});
break;
}
return selectedUser;
});
const toastOptions = {
duration: 4000,
};
const onError = (dataError: APIErrorUser['data']) => {
const messageError =
dataError?.type === OptionType.INVITATION
? t(`Failed to create the invitation for {{email}}.`, {
email: dataError?.value,
})
: t(`Failed to add the user in the document.`);
toast(messageError, VariantType.ERROR, toastOptions);
};
const onSuccess = (option: OptionSelect) => {
const message = !isOptionNewUser(option)
? t('Invitation sent to {{email}}.', {
email: option.value.email,
})
: t('User added to the document.');
toast(message, VariantType.SUCCESS, toastOptions);
};
const handleValidate = async () => {
setIsPending(true);
const settledPromises = await Promise.allSettled<
OptionInvitation | OptionNewUser
>(switchActions(selectedUsers));
onClose();
setIsPending(false);
settledPromises.forEach((settledPromise) => {
switch (settledPromise.status) {
case 'rejected':
onError((settledPromise.reason as APIErrorUser).data);
break;
case 'fulfilled':
onSuccess(settledPromise.value);
break;
}
});
};
return (
<Modal
isOpen
leftActions={
<Button
color="secondary"
fullWidth
onClick={onClose}
disabled={isPending}
>
{t('Cancel')}
</Button>
}
onClose={onClose}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
disabled={!selectedUsers.length || isPending}
onClick={() => void handleValidate()}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconAddUser width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" $margin="none">
{t('Add users to the document')}
</Text>
</Box>
}
>
<GlobalStyle />
<Box $margin={{ bottom: 'xl', top: 'large' }}>
<SearchUsers
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending}
/>
{selectedUsers.length >= 0 && (
<Box $margin={{ top: 'small' }}>
<Text as="h4" $textAlign="left" $margin={{ bottom: 'tiny' }}>
{t('Choose a role')}
</Text>
<ChooseRole
currentRole={currentRole}
disabled={isPending}
defaultRole={Role.READER}
setRole={setSelectedRole}
/>
</Box>
)}
</Box>
</Modal>
);
};

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { Pad } from '@/features/pads/pad-management';
import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useUsers } from '../api/useUsers';
import { OptionSelect, OptionType } from '../types';
export type OptionsSelect = Options<OptionSelect>;
interface SearchUsersProps {
doc: Pad;
selectedUsers: OptionsSelect;
setSelectedUsers: (value: OptionsSelect) => void;
disabled?: boolean;
}
export const SearchUsers = ({
doc,
selectedUsers,
setSelectedUsers,
disabled,
}: SearchUsersProps) => {
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
const resolveOptionsRef = useRef<((value: OptionsSelect) => void) | null>(
null,
);
const { data } = useUsers(
{ query: userQuery, docId: doc.id },
{
enabled: !!userQuery,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);
const options = data?.results;
useEffect(() => {
if (!resolveOptionsRef.current || !options) {
return;
}
const optionsFiltered = options.filter(
(user) =>
!selectedUsers?.find(
(selectedUser) => selectedUser.value.email === user.email,
),
);
let users: OptionsSelect = optionsFiltered.map((user) => ({
value: user,
label: user.email,
type: OptionType.NEW_USER,
}));
if (userQuery && isValidEmail(userQuery)) {
const isFoundUser = !!optionsFiltered.find(
(user) => user.email === userQuery,
);
const isFoundEmail = !!selectedUsers.find(
(selectedUser) => selectedUser.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, selectedUsers]);
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 user to add to the document')}
isMulti
loadOptions={loadOptions}
defaultOptions={[]}
onInputChange={onInputChangeHandle}
inputValue={input}
placeholder={t('Search new users by email')}
noOptionsMessage={() =>
input
? t("We didn't find something matching, try to be more accurate")
: t('Invite new users to {{title}}', { title: doc.title })
}
onChange={(value) => {
setInput('');
setUserQuery('');
setSelectedUsers(value);
}}
/>
);
};

View File

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

View File

@@ -0,0 +1,35 @@
import { User } from '@/core/auth';
import { Pad, Role } from '../pad-management';
export enum OptionType {
INVITATION = 'invitation',
NEW_USER = 'new_user',
}
export const isOptionNewUser = (data: OptionSelect): data is OptionNewUser => {
return 'id' in data.value;
};
export interface OptionInvitation {
value: { email: string };
label: string;
type: OptionType.INVITATION;
}
export interface OptionNewUser {
value: User;
label: string;
type: OptionType.NEW_USER;
}
export type OptionSelect = OptionNewUser | OptionInvitation;
export interface DocInvitation {
id: string;
created_at: string;
email: string;
team: Pad['id'];
role: Role;
issuer: User['id'];
}

View File

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

View File

@@ -30,9 +30,12 @@ export interface Pad {
updated_at: string; updated_at: string;
abilities: { abilities: {
destroy: boolean; destroy: boolean;
retrieve: boolean;
manage_accesses: boolean; manage_accesses: boolean;
update: boolean;
partial_update: boolean; partial_update: boolean;
retrieve: boolean;
update: boolean;
versions_destroy: boolean;
versions_list: boolean;
versions_retrieve: boolean;
}; };
} }

View File

@@ -0,0 +1,11 @@
import { Pad, Role } from './types';
export const currentDocRole = (doc: Pad): Role => {
return doc.abilities.destroy
? Role.OWNER
: doc.abilities.manage_accesses
? Role.ADMIN
: doc.abilities.partial_update
? Role.EDITOR
: Role.READER;
};

View File

@@ -7,8 +7,10 @@ import {
ModalRemovePad, ModalRemovePad,
ModalUpdatePad, ModalUpdatePad,
Pad, Pad,
currentDocRole,
} from '@/features/pads/pad-management'; } from '@/features/pads/pad-management';
import { ModalAddUsers } from '../../addUsers';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { ModalPDF } from './ModalPDF'; import { ModalPDF } from './ModalPDF';
@@ -22,6 +24,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
const { data: templates } = useTemplates({ const { data: templates } = useTemplates({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC, ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
}); });
const [isModalAddUserOpen, setIsModalAddUserOpen] = useState(false);
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false); const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
@@ -57,6 +60,18 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
isOpen={isDropOpen} isOpen={isDropOpen}
> >
<Box> <Box>
{pad.abilities.manage_accesses && (
<Button
onClick={() => {
setIsModalAddUserOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">person_add</span>}
>
<Text $theme="primary">{t('Add a user')}</Text>
</Button>
)}
{pad.abilities.partial_update && ( {pad.abilities.partial_update && (
<Button <Button
onClick={() => { onClick={() => {
@@ -93,6 +108,13 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
</Button> </Button>
</Box> </Box>
</DropButton> </DropButton>
{isModalAddUserOpen && (
<ModalAddUsers
onClose={() => setIsModalAddUserOpen(false)}
doc={pad}
currentRole={currentDocRole(pad)}
/>
)}
{isModalPDFOpen && ( {isModalPDFOpen && (
<ModalPDF <ModalPDF
onClose={() => setIsModalPDFOpen(false)} onClose={() => setIsModalPDFOpen(false)}

View File

@@ -1,22 +1,4 @@
export enum Role { import { Access } from '../pad-management';
READER = 'reader',
EDITOR = 'editor',
ADMIN = 'administrator',
OWNER = 'owner',
}
export interface Access {
id: string;
abilities: {
destroy: boolean;
retrieve: boolean;
set_role_to: Role[];
update: boolean;
};
role: Role;
team: string;
user: string;
}
export interface Template { export interface Template {
id: string; id: string;

View File

@@ -5,19 +5,23 @@
"0 group to display.": "0 groupe à afficher.", "0 group to display.": "0 groupe à afficher.",
"Accessibility": "Accessibilité", "Accessibility": "Accessibilité",
"Add a document": "Ajouter un document", "Add a document": "Ajouter un document",
"Add a user": "Ajouter un utilisateur",
"Add document icon": "Icône ajouter un document", "Add document icon": "Icône ajouter un document",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}} \" ?", "Add users to the document": "Ajouter des utilisateurs au document",
"Administrator": "Administrateur",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Back to home page": "Retour à l'accueil", "Back to home page": "Retour à l'accueil",
"Cancel": "Annuler", "Cancel": "Annuler",
"Close the docs panel": "Fermer le panneau des docs", "Choose a role": "Choisissez un rôle",
"Close the documents panel": "Fermer le panneau des documents",
"Close the modal": "Fermer la modale", "Close the modal": "Fermer la modale",
"Coming soon ...": "Coming soon ...", "Coming soon ...": "Coming soon ...",
"Confirm deletion": "Confirmer la suppression", "Confirm deletion": "Confirmer la suppression",
"Content modal to delete document": "Contenu modal pour supprimer le document", "Content modal to delete document": "Contenu modal pour supprimer le document",
"Content modal to generate a PDF": "Contenu modal pour générer un PDF", "Content modal to generate a PDF": "Contenu modal pour générer un PDF",
"Content modal to update the document": "Contenu modal pour mettre à jour le groupe", "Content modal to update the document": "Contenu modal pour mettre à jour le document",
"Create a new document": "Créer un nouveau document", "Create a new document": "Créer un nouveau document",
"Create new document card": "Carte créer un nouveau groupe", "Create new document card": "Carte créer un nouveau document",
"Create the document": "Créer le document", "Create the document": "Créer le document",
"Create your first document by clicking on the \"Create a new document\" button.": "Créez votre premier document en cliquant sur le bouton \"Créer un nouveau document\".", "Create your first document by clicking on the \"Create a new document\" button.": "Créez votre premier document en cliquant sur le bouton \"Créer un nouveau document\".",
"Delete document": "Supprimer le document", "Delete document": "Supprimer le document",
@@ -29,11 +33,17 @@
"Document name": "Nom du document", "Document name": "Nom du document",
"Documents": "Documents", "Documents": "Documents",
"Download": "Télécharger", "Download": "Télécharger",
"Editor": "Éditeur",
"Empty pads icon": "Icône des pads vides", "Empty pads icon": "Icône des pads vides",
"Enter the new name of the selected document.": "Entrez le nouveau nom du document sélectionné.", "Enter the new name of the selected document.": "Entrez le nouveau nom du document sélectionné.",
"Failed to add the user in the document.": "Échec de l'ajout de l'utilisateur dans le document.",
"Failed to create the invitation for {{email}}.": "Impossible de créer l'invitation pour {{email}}.",
"Find a user to add to the document": "Trouver un utilisateur à ajouter au document",
"Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité", "Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité",
"Generate PDF": "Générer PDF", "Generate PDF": "Générer PDF",
"Generate a PDF from your document, it will be inserted in the selected template.": "Générez un PDF à partir de votre document, il sera inséré dans le modèle sélectionné.", "Generate a PDF from your document, it will be inserted in the selected template.": "Générez un PDF à partir de votre document, il sera inséré dans le modèle sélectionné.",
"Invitation sent to {{email}}.": "Invitation envoyée à {{email}}.",
"Invite new users to {{title}}": "Inviter de nouveaux utilisateurs à {{title}}",
"Is it public ?": "Est-ce public?", "Is it public ?": "Est-ce public?",
"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.",
"Language": "Langue", "Language": "Langue",
@@ -45,11 +55,14 @@
"My account": "Mon compte", "My account": "Mon compte",
"Name the document": "Nommer le document", "Name the document": "Nommer le document",
"No editor found": "Pas d'éditeur trouvé", "No editor found": "Pas d'éditeur trouvé",
"Open the docs panel": "Ouvrir le panneau des docs",
"Open the document options": "Ouvrir les options du document", "Open the document options": "Ouvrir les options du document",
"Open the documents panel": "Ouvrir le panneau des documents",
"Ouch !": "Aïe !", "Ouch !": "Aïe !",
"Owner": "Propriétaire",
"Pads icon": "Icône de pads", "Pads icon": "Icône de pads",
"Personal data and cookies": "Données personnelles et cookies", "Personal data and cookies": "Données personnelles et cookies",
"Reader": "Lecteur",
"Search new users by email": "Rechercher de nouveaux utilisateurs par email",
"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.",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.", "Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
"Sort documents icon": "Icône trier les documents", "Sort documents icon": "Icône trier les documents",
@@ -58,9 +71,13 @@
"Template": "Template", "Template": "Template",
"The document has been deleted.": "Le document a bien été supprimé.", "The document has been deleted.": "Le document a bien été supprimé.",
"The document has been updated.": "Le document a été mis à jour.", "The document has been updated.": "Le document a été mis à jour.",
"Unless otherwise stated, all content on this site is under": "Sauf mention contraire, tout le contenu de ce site est sous",
"Update document": "Mettre à jour le document", "Update document": "Mettre à jour le document",
"Update document \"{{documentTitle}}\"": "Mettre à jour le document \"{{documentTitle}}\"", "Update document \"{{documentTitle}}\"": "Mettre à jour le document \"{{documentTitle}}\"",
"User added to the document.": "Utilisateur ajouté au document.",
"Validate": "Valider",
"Validate the modification": "Valider les modifications", "Validate the modification": "Valider les modifications",
"We didn't find something matching, try to be more accurate": "Nous n'avons pas trouvé de concordance, essayez d'être plus précis",
"Your pdf was downloaded succesfully": "Votre pdf a été téléchargé avec succès", "Your pdf was downloaded succesfully": "Votre pdf a été téléchargé avec succès",
"icon group": "icône groupe" "icon group": "icône groupe"
} }

View File

@@ -1,5 +1,5 @@
export const isValidEmail = (email: string) => { export const isValidEmail = (email: string) => {
return !!email.match( return !!email.match(
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z\-0-9]{2,}))$/,
); );
}; };