diff --git a/CHANGELOG.md b/CHANGELOG.md index add2a383..81f78116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to - Remove document (#68) - (docker) dockerize dev frontend (#63) - (backend) list users with email filtering (#79) +- (frontend) add user to a document (#52) +- (frontend) invite user to a document (#52) ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/pad-add-members.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/pad-add-members.spec.ts new file mode 100644 index 00000000..d62249e3 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/pad-add-members.spec.ts @@ -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(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/api/index.ts b/src/frontend/apps/impress/src/features/pads/addUsers/api/index.ts new file mode 100644 index 00000000..0696d634 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/api/index.ts @@ -0,0 +1,3 @@ +export * from './useCreateDocInvitation'; +export * from './useCreateDocAccess'; +export * from './useUsers'; diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/api/useCreateDocAccess.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/api/useCreateDocAccess.tsx new file mode 100644 index 00000000..313fb7c3 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/api/useCreateDocAccess.tsx @@ -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 => { + 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; +}; + +export function useCreateDocAccess() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDocAccess, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_PAD], + }); + void queryClient.resetQueries({ + queryKey: [KEY_LIST_USER], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/api/useCreateDocInvitation.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/api/useCreateDocInvitation.tsx new file mode 100644 index 00000000..e70bf328 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/api/useCreateDocInvitation.tsx @@ -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 => { + 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; +}; + +export function useCreateInvitation() { + return useMutation({ + mutationFn: createDocInvitation, + }); +} diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/api/useUsers.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/api/useUsers.tsx new file mode 100644 index 00000000..552d77d9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/api/useUsers.tsx @@ -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; + +export const getUsers = async ({ + query, + docId, +}: UsersParams): Promise => { + 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; +}; + +export const KEY_LIST_USER = 'users'; + +export function useUsers( + param: UsersParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_USER, param], + queryFn: () => getUsers(param), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/assets/add-user.svg b/src/frontend/apps/impress/src/features/pads/addUsers/assets/add-user.svg new file mode 100644 index 00000000..08de3aae --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/assets/add-user.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/components/ChooseRole.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/components/ChooseRole.tsx new file mode 100644 index 00000000..3e4b86c1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/components/ChooseRole.tsx @@ -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 ( + + {Object.values(Role).map((role) => ( + setRole(evt.target.value as Role)} + defaultChecked={defaultRole === role} + disabled={ + disabled || (currentRole !== Role.OWNER && role === Role.OWNER) + } + /> + ))} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/components/ModalAddUsers.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/components/ModalAddUsers.tsx new file mode 100644 index 00000000..7c66b61e --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/components/ModalAddUsers.tsx @@ -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([]); + const [selectedRole, setSelectedRole] = useState(Role.READER); + const { toast } = useToastProvider(); + const { mutateAsync: createInvitation } = useCreateInvitation(); + const { mutateAsync: createDocAccess } = useCreateDocAccess(); + + const [isPending, setIsPending] = useState(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 ( + + {t('Cancel')} + + } + onClose={onClose} + closeOnClickOutside + hideCloseButton + rightActions={ + + } + size={ModalSize.MEDIUM} + title={ + + + + {t('Add users to the document')} + + + } + > + + + + {selectedUsers.length >= 0 && ( + + + {t('Choose a role')} + + + + )} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/components/SearchUsers.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/components/SearchUsers.tsx new file mode 100644 index 00000000..d77bf7d5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/components/SearchUsers.tsx @@ -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; + +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 => { + return new Promise((resolve) => { + resolveOptionsRef.current = resolve; + }); + }; + + const timeout = useRef(null); + const onInputChangeHandle = useCallback((newValue: string) => { + setInput(newValue); + if (timeout.current) { + clearTimeout(timeout.current); + } + + timeout.current = setTimeout(() => { + setUserQuery(newValue); + }, 1000); + }, []); + + return ( + + 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); + }} + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/index.ts b/src/frontend/apps/impress/src/features/pads/addUsers/index.ts new file mode 100644 index 00000000..42604bc2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/index.ts @@ -0,0 +1 @@ +export * from './components/ModalAddUsers'; diff --git a/src/frontend/apps/impress/src/features/pads/addUsers/types.tsx b/src/frontend/apps/impress/src/features/pads/addUsers/types.tsx new file mode 100644 index 00000000..c7a028ef --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/addUsers/types.tsx @@ -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']; +} diff --git a/src/frontend/apps/impress/src/features/pads/pad-management/index.ts b/src/frontend/apps/impress/src/features/pads/pad-management/index.ts index 314dad0c..b4debe96 100644 --- a/src/frontend/apps/impress/src/features/pads/pad-management/index.ts +++ b/src/frontend/apps/impress/src/features/pads/pad-management/index.ts @@ -1,3 +1,4 @@ export * from './api'; export * from './components'; export * from './types'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/pads/pad-management/types.tsx b/src/frontend/apps/impress/src/features/pads/pad-management/types.tsx index 733d3527..f03c1ae3 100644 --- a/src/frontend/apps/impress/src/features/pads/pad-management/types.tsx +++ b/src/frontend/apps/impress/src/features/pads/pad-management/types.tsx @@ -30,9 +30,12 @@ export interface Pad { updated_at: string; abilities: { destroy: boolean; - retrieve: boolean; manage_accesses: boolean; - update: boolean; partial_update: boolean; + retrieve: boolean; + update: boolean; + versions_destroy: boolean; + versions_list: boolean; + versions_retrieve: boolean; }; } diff --git a/src/frontend/apps/impress/src/features/pads/pad-management/utils.ts b/src/frontend/apps/impress/src/features/pads/pad-management/utils.ts new file mode 100644 index 00000000..99e9682e --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/pad-management/utils.ts @@ -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; +}; diff --git a/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx b/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx index ded53173..5c8852c6 100644 --- a/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx +++ b/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx @@ -7,8 +7,10 @@ import { ModalRemovePad, ModalUpdatePad, Pad, + currentDocRole, } from '@/features/pads/pad-management'; +import { ModalAddUsers } from '../../addUsers'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; import { ModalPDF } from './ModalPDF'; @@ -22,6 +24,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => { const { data: templates } = useTemplates({ ordering: TemplatesOrdering.BY_CREATED_ON_DESC, }); + const [isModalAddUserOpen, setIsModalAddUserOpen] = useState(false); const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); @@ -57,6 +60,18 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => { isOpen={isDropOpen} > + {pad.abilities.manage_accesses && ( + + )} {pad.abilities.partial_update && ( + {isModalAddUserOpen && ( + setIsModalAddUserOpen(false)} + doc={pad} + currentRole={currentDocRole(pad)} + /> + )} {isModalPDFOpen && ( setIsModalPDFOpen(false)} diff --git a/src/frontend/apps/impress/src/features/pads/pad-tools/types.ts b/src/frontend/apps/impress/src/features/pads/pad-tools/types.ts index bac7d27e..8c81feb0 100644 --- a/src/frontend/apps/impress/src/features/pads/pad-tools/types.ts +++ b/src/frontend/apps/impress/src/features/pads/pad-tools/types.ts @@ -1,22 +1,4 @@ -export enum Role { - 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; -} +import { Access } from '../pad-management'; export interface Template { id: string; diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 79d21e59..49bc119d 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -5,19 +5,23 @@ "0 group to display.": "0 groupe à afficher.", "Accessibility": "Accessibilité", "Add a document": "Ajouter un document", + "Add a user": "Ajouter un utilisateur", "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", "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", "Coming soon ...": "Coming soon ...", "Confirm deletion": "Confirmer la suppression", "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 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 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 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", @@ -29,11 +33,17 @@ "Document name": "Nom du document", "Documents": "Documents", "Download": "Télécharger", + "Editor": "Éditeur", "Empty pads icon": "Icône des pads vides", "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é", "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é.", + "Invitation sent to {{email}}.": "Invitation envoyée à {{email}}.", + "Invite new users to {{title}}": "Inviter de nouveaux utilisateurs à {{title}}", "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.", "Language": "Langue", @@ -45,11 +55,14 @@ "My account": "Mon compte", "Name the document": "Nommer le document", "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 documents panel": "Ouvrir le panneau des documents", "Ouch !": "Aïe !", + "Owner": "Propriétaire", "Pads icon": "Icône de pads", "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 retry.": "Une erreur inattendue s'est produite, veuillez réessayer.", "Sort documents icon": "Icône trier les documents", @@ -58,9 +71,13 @@ "Template": "Template", "The document has been deleted.": "Le document a bien été supprimé.", "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 \"{{documentTitle}}\"": "Mettre à jour le document \"{{documentTitle}}\"", + "User added to the document.": "Utilisateur ajouté au document.", + "Validate": "Valider", "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", "icon group": "icône groupe" } diff --git a/src/frontend/apps/impress/src/utils/string.ts b/src/frontend/apps/impress/src/utils/string.ts index b86789f4..04a7e4f9 100644 --- a/src/frontend/apps/impress/src/utils/string.ts +++ b/src/frontend/apps/impress/src/utils/string.ts @@ -1,5 +1,5 @@ export const isValidEmail = (email: string) => { 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,}))$/, ); };