diff --git a/CHANGELOG.md b/CHANGELOG.md
index d04afb30..5d76c65a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to
- 🌐Internationalize invitation email #167
- ✨(frontend) White branding #164
- ✨Email invitation when add user to doc #171
+- ✨Invitation management #174
## Fixed
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts
index 5ef1d05a..8450b6f3 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts
@@ -124,6 +124,15 @@ test.describe('Document create member', () => {
expect(responseAddUser.request().headers()['content-language']).toBe(
'en-us',
);
+
+ const listInvitation = page.getByLabel('List invitation card');
+ await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
+ await expect(
+ listInvitation.locator('li').getByText('Invited'),
+ ).toBeVisible();
+
+ const listMember = page.getByLabel('List members card');
+ await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
});
test('it try to add twice the same user', async ({ page, browserName }) => {
@@ -255,4 +264,47 @@ test.describe('Document create member', () => {
responseCreateInvitation.request().headers()['content-language'],
).toBe('fr-fr');
});
+
+ test('it manages invitation', async ({ page, browserName }) => {
+ await createDoc(page, 'user-invitation', browserName, 1);
+
+ await page.getByRole('button', { name: 'Share' }).click();
+
+ const inputSearch = page.getByLabel(/Find a member 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('combobox', { name: /Choose a role/ }).click();
+ await page.getByRole('option', { name: 'Administrator' }).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();
+
+ const listInvitation = page.getByLabel('List invitation card');
+ const li = listInvitation.locator('li').filter({
+ hasText: email,
+ });
+ await expect(li.getByText(email)).toBeVisible();
+
+ await li.getByRole('combobox', { name: /Role/ }).click();
+ await li.getByRole('option', { name: 'Reader' }).click();
+ await expect(page.getByText(`The role has been updated.`)).toBeVisible();
+ await li.getByText('delete').click();
+ await expect(
+ page.getByText(`The invitation has been removed.`),
+ ).toBeVisible();
+ await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
+ });
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx
index 20aa8469..25f68c83 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx
@@ -2,11 +2,11 @@ import { t } from 'i18next';
import { useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
-import { Box, Card, Text } from '@/components';
-import { SideModal } from '@/components/SideModal';
+import { Box, Card, SideModal, Text } from '@/components';
+import { InvitationList } from '@/features/docs/members/invitation-list';
+import { AddMembers } from '@/features/docs/members/members-add';
+import { MemberList } from '@/features/docs/members/members-list';
-import { AddMembers } from '../../members/members-add';
-import { MemberList } from '../../members/members-list/components/MemberList';
import { Doc } from '../types';
import { currentDocRole } from '../utils';
@@ -67,6 +67,7 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
}
>
+
>
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts
new file mode 100644
index 00000000..b76a3b26
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts
@@ -0,0 +1,3 @@
+export * from './useDeleteDocInvitation';
+export * from './useDocInvitations';
+export * from './useUpdateDocInvitation';
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts
new file mode 100644
index 00000000..5874cd8c
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts
@@ -0,0 +1,62 @@
+import {
+ UseMutationOptions,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query';
+
+import { APIError, errorCauses, fetchAPI } from '@/api';
+
+import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
+
+interface DeleteDocInvitationProps {
+ docId: string;
+ invitationId: string;
+}
+
+export const deleteDocInvitation = async ({
+ docId,
+ invitationId,
+}: DeleteDocInvitationProps): Promise => {
+ const response = await fetchAPI(
+ `documents/${docId}/invitations/${invitationId}/`,
+ {
+ method: 'DELETE',
+ },
+ );
+
+ if (!response.ok) {
+ throw new APIError(
+ 'Failed to delete the invitation',
+ await errorCauses(response),
+ );
+ }
+};
+
+type UseDeleteDocInvitationOptions = UseMutationOptions<
+ void,
+ APIError,
+ DeleteDocInvitationProps
+>;
+
+export const useDeleteDocInvitation = (
+ options?: UseDeleteDocInvitationOptions,
+) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: deleteDocInvitation,
+ ...options,
+ onSuccess: (data, variables, context) => {
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_DOC_INVITATIONS],
+ });
+ if (options?.onSuccess) {
+ options.onSuccess(data, variables, context);
+ }
+ },
+ onError: (error, variables, context) => {
+ if (options?.onError) {
+ options.onError(error, variables, context);
+ }
+ },
+ });
+};
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx
new file mode 100644
index 00000000..55a29112
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx
@@ -0,0 +1,99 @@
+import {
+ DefinedInitialDataInfiniteOptions,
+ InfiniteData,
+ QueryKey,
+ UseQueryOptions,
+ useInfiniteQuery,
+ useQuery,
+} from '@tanstack/react-query';
+
+import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
+
+import { Invitation } from '../types';
+
+export type DocInvitationsParams = {
+ docId: string;
+ ordering?: string;
+};
+
+export type DocInvitationsAPIParams = DocInvitationsParams & {
+ page: number;
+};
+
+type DocInvitationsResponse = APIList;
+
+export const getDocInvitations = async ({
+ page,
+ docId,
+ ordering,
+}: DocInvitationsAPIParams): Promise => {
+ let url = `documents/${docId}/invitations/?page=${page}`;
+
+ if (ordering) {
+ url += '&ordering=' + ordering;
+ }
+
+ const response = await fetchAPI(url);
+
+ if (!response.ok) {
+ throw new APIError(
+ 'Failed to get the doc accesses',
+ await errorCauses(response),
+ );
+ }
+
+ return response.json() as Promise;
+};
+
+export const KEY_LIST_DOC_INVITATIONS = 'docs-invitations';
+
+export function useDocInvitations(
+ params: DocInvitationsAPIParams,
+ queryConfig?: UseQueryOptions<
+ DocInvitationsResponse,
+ APIError,
+ DocInvitationsResponse
+ >,
+) {
+ return useQuery({
+ queryKey: [KEY_LIST_DOC_INVITATIONS, params],
+ queryFn: () => getDocInvitations(params),
+ ...queryConfig,
+ });
+}
+
+/**
+ * @param param Used for infinite scroll pagination
+ * @param queryConfig
+ * @returns
+ */
+export function useDocInvitationsInfinite(
+ param: DocInvitationsParams,
+ queryConfig?: DefinedInitialDataInfiniteOptions<
+ DocInvitationsResponse,
+ APIError,
+ InfiniteData,
+ QueryKey,
+ number
+ >,
+) {
+ return useInfiniteQuery<
+ DocInvitationsResponse,
+ APIError,
+ InfiniteData,
+ QueryKey,
+ number
+ >({
+ initialPageParam: 1,
+ queryKey: [KEY_LIST_DOC_INVITATIONS, param],
+ queryFn: ({ pageParam }) =>
+ getDocInvitations({
+ ...param,
+ page: pageParam,
+ }),
+ getNextPageParam(lastPage, allPages) {
+ return lastPage.next ? allPages.length + 1 : undefined;
+ },
+ ...queryConfig,
+ });
+}
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts
new file mode 100644
index 00000000..435ea963
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts
@@ -0,0 +1,71 @@
+import {
+ UseMutationOptions,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query';
+
+import { APIError, errorCauses, fetchAPI } from '@/api';
+import { Role } from '@/features/docs/doc-management';
+
+import { Invitation } from '../types';
+
+import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
+
+interface UpdateDocInvitationProps {
+ docId: string;
+ invitationId: string;
+ role: Role;
+}
+
+export const updateDocInvitation = async ({
+ docId,
+ invitationId,
+ role,
+}: UpdateDocInvitationProps): Promise => {
+ const response = await fetchAPI(
+ `documents/${docId}/invitations/${invitationId}/`,
+ {
+ method: 'PATCH',
+ body: JSON.stringify({
+ role,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ throw new APIError('Failed to update role', await errorCauses(response));
+ }
+
+ return response.json() as Promise;
+};
+
+type UseUpdateDocInvitation = Partial;
+
+type UseUpdateDocInvitationOptions = UseMutationOptions<
+ Invitation,
+ APIError,
+ UseUpdateDocInvitation
+>;
+
+export const useUpdateDocInvitation = (
+ options?: UseUpdateDocInvitationOptions,
+) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: updateDocInvitation,
+ ...options,
+ onSuccess: (data, variables, context) => {
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_DOC_INVITATIONS],
+ });
+ if (options?.onSuccess) {
+ options.onSuccess(data, variables, context);
+ }
+ },
+ onError: (error, variables, context) => {
+ if (options?.onError) {
+ options.onError(error, variables, context);
+ }
+ },
+ });
+};
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx
new file mode 100644
index 00000000..678f77a1
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx
@@ -0,0 +1,129 @@
+import {
+ Button,
+ Loader,
+ VariantType,
+ useToastProvider,
+} from '@openfun/cunningham-react';
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, IconBG, Text, TextErrors } from '@/components';
+import { useCunninghamTheme } from '@/cunningham';
+import { Role } from '@/features/docs/doc-management';
+import { ChooseRole } from '@/features/docs/members/members-add/';
+
+import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
+import { Invitation } from '../types';
+
+interface InvitationItemProps {
+ role: Role;
+ currentRole: Role;
+ invitation: Invitation;
+ docId: string;
+}
+
+export const InvitationItem = ({
+ docId,
+ role,
+ invitation,
+ currentRole,
+}: InvitationItemProps) => {
+ const canDelete = invitation.abilities.destroy;
+ const canUpdate = invitation.abilities.partial_update;
+ const { t } = useTranslation();
+ const [localRole, setLocalRole] = useState(role);
+ const { colorsTokens } = useCunninghamTheme();
+ const { toast } = useToastProvider();
+ const { mutate: updateDocInvitation, error: errorUpdate } =
+ useUpdateDocInvitation({
+ onSuccess: () => {
+ toast(t('The role has been updated.'), VariantType.SUCCESS, {
+ duration: 4000,
+ });
+ },
+ });
+
+ const { mutate: removeDocInvitation, error: errorDelete } =
+ useDeleteDocInvitation({
+ onSuccess: () => {
+ toast(t('The invitation has been removed.'), VariantType.SUCCESS, {
+ duration: 4000,
+ });
+ },
+ });
+
+ if (!invitation.email) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {t('Invited')}
+
+ {invitation.email}
+
+
+
+ {
+ setLocalRole(role);
+ updateDocInvitation({
+ docId,
+ invitationId: invitation.id,
+ role,
+ });
+ }}
+ />
+
+
+
+
+ {(errorUpdate || errorDelete) && (
+
+ )}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationList.tsx b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationList.tsx
new file mode 100644
index 00000000..893ec7da
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationList.tsx
@@ -0,0 +1,127 @@
+import { Loader } from '@openfun/cunningham-react';
+import React, { useMemo, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { APIError } from '@/api';
+import { Box, Card, InfiniteScroll, TextErrors } from '@/components';
+import { useCunninghamTheme } from '@/cunningham';
+import { Doc, currentDocRole } from '@/features/docs/doc-management';
+
+import { useDocInvitationsInfinite } from '../api';
+import { Invitation } from '../types';
+
+import { InvitationItem } from './InvitationItem';
+
+interface InvitationListStateProps {
+ isLoading: boolean;
+ error: APIError | null;
+ invitations?: Invitation[];
+ doc: Doc;
+}
+
+const InvitationListState = ({
+ invitations,
+ error,
+ isLoading,
+ doc,
+}: InvitationListStateProps) => {
+ const { colorsTokens } = useCunninghamTheme();
+
+ if (error) {
+ return ;
+ }
+
+ if (isLoading || !invitations) {
+ return (
+
+
+
+ );
+ }
+
+ return invitations?.map((invitation, index) => {
+ if (!invitation.email) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ });
+};
+
+interface InvitationListProps {
+ doc: Doc;
+}
+
+export const InvitationList = ({ doc }: InvitationListProps) => {
+ const { t } = useTranslation();
+ const containerRef = useRef(null);
+ const {
+ data,
+ isLoading,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useDocInvitationsInfinite({
+ docId: doc.id,
+ });
+
+ const invitations = useMemo(() => {
+ return data?.pages.reduce((acc, page) => {
+ return acc.concat(page.results);
+ }, [] as Invitation[]);
+ }, [data?.pages]);
+
+ if (!invitations?.length) {
+ return null;
+ }
+
+ return (
+
+
+ {
+ void fetchNextPage();
+ }}
+ scrollContainer={containerRef.current}
+ as="ul"
+ className="p-0 mt-0"
+ role="listbox"
+ >
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/index.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/index.ts
new file mode 100644
index 00000000..68dace93
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/index.ts
@@ -0,0 +1 @@
+export * from './InvitationList';
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/conf.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/conf.ts
new file mode 100644
index 00000000..bfab9067
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/conf.ts
@@ -0,0 +1 @@
+export const PAGE_SIZE = 20;
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/index.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/index.ts
new file mode 100644
index 00000000..0ef46430
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/index.ts
@@ -0,0 +1,2 @@
+export * from './api';
+export * from './components';
diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/types.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/types.ts
new file mode 100644
index 00000000..2d24b957
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/types.ts
@@ -0,0 +1,17 @@
+import { Role } from '@/features/docs/doc-management';
+
+export interface Invitation {
+ id: string;
+ role: Role;
+ document: string;
+ created_at: string;
+ is_expired: boolean;
+ issuer: string;
+ email: string;
+ abilities: {
+ destroy: boolean;
+ retrieve: boolean;
+ partial_update: boolean;
+ update: boolean;
+ };
+}
diff --git a/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocInvitation.tsx b/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocInvitation.tsx
index c443a112..b457e93d 100644
--- a/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocInvitation.tsx
+++ b/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocInvitation.tsx
@@ -1,10 +1,11 @@
-import { useMutation } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Doc, Role } from '@/features/docs/doc-management';
import { ContentLanguage } from '@/i18n/types';
+import { KEY_LIST_DOC_INVITATIONS } from '../../invitation-list';
import { DocInvitation, OptionType } from '../types';
interface CreateDocInvitationParams {
@@ -45,7 +46,13 @@ export const createDocInvitation = async ({
};
export function useCreateInvitation() {
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: createDocInvitation,
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: [KEY_LIST_DOC_INVITATIONS],
+ });
+ },
});
}