From 2360a832afcb9618c14f574791f86fe00fb64966 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 24 Jun 2025 17:42:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20access=20request=20?= =?UTF-8?q?on=20doc=20share=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the access request to the document share modal, allowing admin to see and manage access requests directly from the modal interface. --- CHANGELOG.md | 3 +- .../apps/e2e/__tests__/app-impress/common.ts | 6 +- .../app-impress/doc-member-create.spec.ts | 102 +++++++++++- .../app-impress/doc-member-list.spec.ts | 2 +- .../doc-share/api/useDocAccessRequest.tsx | 154 ++++++++++++++++-- .../components/DocShareAccessRequest.tsx | 149 +++++++++++++++++ .../doc-share/components/DocShareModal.tsx | 5 + 7 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6476ea95..8af63ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - 🔧(front) configure x-frame-options to DENY in nginx conf #1084 - (doc) add documentation to install with compose #855 - ✨(backend) allow to disable checking unsafe mimetype on attachment upload +- ✨Ask for access #1081 ### Changed @@ -33,7 +34,7 @@ and this project adheres to - 🔧(git) set LF line endings for all text files #1032 - 📝(docs) minor fixes to docs/env.md -## Removed +### Removed - 🔥(frontend) remove Beta from logo #1095 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 9c5d5b02..c91c42d7 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -1,5 +1,7 @@ import { Page, expect } from '@playwright/test'; +export const BROWSERS = ['chromium', 'webkit', 'firefox']; + export const CONFIG = { AI_FEATURE_ENABLED: true, CRISP_WEBSITE_ID: null, @@ -328,4 +330,6 @@ export const mockedAccesses = async (page: Page, json?: object) => { export const expectLoginPage = async (page: Page) => await expect( page.getByRole('heading', { name: 'Collaborative writing' }), - ).toBeVisible(); + ).toBeVisible({ + timeout: 10000, + }); 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 f47d0918..d0a98028 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 @@ -1,12 +1,18 @@ import { expect, test } from '@playwright/test'; -import { createDoc, randomName } from './common'; - -test.beforeEach(async ({ page }) => { - await page.goto('/'); -}); +import { + BROWSERS, + createDoc, + keyCloakSignIn, + randomName, + verifyDocName, +} from './common'; test.describe('Document create member', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + test('it selects 2 users and 1 invitation', async ({ page, browserName }) => { const inputFill = 'user '; const responsePromise = page.waitForResponse( @@ -203,3 +209,89 @@ test.describe('Document create member', () => { await expect(userInvitation).toBeHidden(); }); }); + +test.describe('Document create member: Multiple login', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('It creates a member from a request coming from a 403 page', async ({ + page, + browserName, + }) => { + test.slow(); + + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc( + page, + 'Member access request', + browserName, + 1, + ); + + await verifyDocName(page, docTitle); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + const otherBrowser = BROWSERS.find((b) => b !== browserName); + + await keyCloakSignIn(page, otherBrowser!); + + await expect( + page.getByRole('link', { name: 'Docs Logo Docs' }), + ).toBeVisible(); + + await page.goto(urlDoc); + + await expect( + page.getByText('Insufficient access rights to view the document.'), + ).toBeVisible({ + timeout: 10000, + }); + + await page.getByRole('button', { name: 'Request access' }).click(); + + await expect( + page.getByText('Your access request for this document is pending.'), + ).toBeVisible(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + await expect( + page.getByRole('link', { name: 'Docs Logo Docs' }), + ).toBeVisible(); + + await page.goto(urlDoc); + + await page.getByRole('button', { name: 'Share' }).click(); + + await expect(page.getByText('Access Requests')).toBeVisible(); + await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible(); + + const emailRequest = `user@${otherBrowser}.test`; + await expect(page.getByText(emailRequest)).toBeVisible(); + const container = page.getByTestId( + `doc-share-access-request-row-${emailRequest}`, + ); + await container.getByLabel('doc-role-dropdown').click(); + await page.getByLabel('Administrator').click(); + await container.getByRole('button', { name: 'Approve' }).click(); + + await expect(page.getByText('Access Requests')).toBeHidden(); + await expect(page.getByText('Share with 2 users')).toBeVisible(); + await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts index 3a134b86..f92a7d4c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts @@ -216,7 +216,7 @@ test.describe('Document list members', () => { await mySelfMoreActions.click(); await page.getByLabel('Delete').click(); await expect( - page.getByText('You do not have permission to view this document.'), + page.getByText('Insufficient access rights to view the document.'), ).toBeVisible(); }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx index 062edba6..313d879d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx @@ -6,11 +6,19 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { + APIError, + APIList, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} from '@/api'; import { AccessRequest, Doc, Role } from '@/docs/doc-management'; import { OptionType } from '../types'; +import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; + interface CreateDocAccessRequestParams { docId: Doc['id']; role?: Role; @@ -19,7 +27,7 @@ interface CreateDocAccessRequestParams { export const createDocAccessRequest = async ({ docId, role, -}: CreateDocAccessRequestParams): Promise => { +}: CreateDocAccessRequestParams): Promise => { const response = await fetchAPI(`documents/${docId}/ask-for-access/`, { method: 'POST', body: JSON.stringify({ @@ -35,12 +43,10 @@ export const createDocAccessRequest = async ({ }), ); } - - return null; }; type UseCreateDocAccessRequestOptions = UseMutationOptions< - null, + void, APIError, CreateDocAccessRequestParams >; @@ -50,7 +56,7 @@ export function useCreateDocAccessRequest( ) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: createDocAccessRequest, ...options, onSuccess: (data, variables, context) => { @@ -65,14 +71,21 @@ export function useCreateDocAccessRequest( type AccessRequestResponse = APIList; -interface GetDocAccessRequestsParams { +interface DocAccessRequestsParams { docId: Doc['id']; } +export type DocAccessRequestsAPIParams = DocAccessRequestsParams & { + page: number; +}; + export const getDocAccessRequests = async ({ docId, -}: GetDocAccessRequestsParams): Promise => { - const response = await fetchAPI(`documents/${docId}/ask-for-access/`); + page, +}: DocAccessRequestsAPIParams): Promise => { + const response = await fetchAPI( + `documents/${docId}/ask-for-access/?page=${page}`, + ); if (!response.ok) { throw new APIError( @@ -87,7 +100,7 @@ export const getDocAccessRequests = async ({ export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests'; export function useDocAccessRequests( - params: GetDocAccessRequestsParams, + params: DocAccessRequestsAPIParams, queryConfig?: UseQueryOptions< AccessRequestResponse, APIError, @@ -100,3 +113,124 @@ export function useDocAccessRequests( ...queryConfig, }); } + +export const useDocAccessRequestsInfinite = ( + params: DocAccessRequestsParams, +) => { + return useAPIInfiniteQuery( + KEY_LIST_DOC_ACCESS_REQUESTS, + getDocAccessRequests, + params, + ); +}; + +interface acceptDocAccessRequestsParams { + docId: string; + accessRequestId: string; + role: Role; +} + +export const acceptDocAccessRequests = async ({ + docId, + accessRequestId, + role, +}: acceptDocAccessRequestsParams): Promise => { + const response = await fetchAPI( + `documents/${docId}/ask-for-access/${accessRequestId}/accept/`, + { + method: 'POST', + body: JSON.stringify({ + role, + }), + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to accept the access request', + await errorCauses(response), + ); + } +}; + +type UseAcceptDocAccessRequests = Partial; + +type UseAcceptDocAccessRequestsOptions = UseMutationOptions< + void, + APIError, + UseAcceptDocAccessRequests +>; + +export const useAcceptDocAccessRequest = ( + options?: UseAcceptDocAccessRequestsOptions, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: acceptDocAccessRequests, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC_ACCESSES], + }); + + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS], + }); + + if (options?.onSuccess) { + void options.onSuccess(data, variables, context); + } + }, + }); +}; + +interface DeleteDocAccessRequestParams { + docId: string; + accessRequestId: string; +} + +export const deleteDocAccessRequest = async ({ + docId, + accessRequestId, +}: DeleteDocAccessRequestParams): Promise => { + const response = await fetchAPI( + `documents/${docId}/ask-for-access/${accessRequestId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete the access request', + await errorCauses(response), + ); + } +}; + +type UseDeleteDocAccessRequestOptions = UseMutationOptions< + void, + APIError, + DeleteDocAccessRequestParams +>; + +export const useDeleteDocAccessRequest = ( + options?: UseDeleteDocAccessRequestOptions, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteDocAccessRequest, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS], + }); + + if (options?.onSuccess) { + void options.onSuccess(data, variables, context); + } + }, + }); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx new file mode 100644 index 00000000..4def720d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx @@ -0,0 +1,149 @@ +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createGlobalStyle } from 'styled-components'; + +import { Box, BoxButton, Icon, LoadMoreText } from '@/components'; +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; +import { useCunninghamTheme } from '@/cunningham'; +import { AccessRequest, Doc } from '@/docs/doc-management/'; + +import { + useAcceptDocAccessRequest, + useDeleteDocAccessRequest, + useDocAccessRequestsInfinite, +} from '../api/useDocAccessRequest'; + +import { DocRoleDropdown } from './DocRoleDropdown'; +import { SearchUserRow } from './SearchUserRow'; + +const QuickSearchGroupAccessRequestStyle = createGlobalStyle` + .--docs--share-access-request [cmdk-item][data-selected='true'] { + background: inherit + } +`; + +type Props = { + doc: Doc; + accessRequest: AccessRequest; +}; + +const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const { spacingsTokens } = useCunninghamTheme(); + const { mutate: acceptDocAccessRequests } = useAcceptDocAccessRequest(); + const [role, setRole] = useState(accessRequest.role); + + const { mutate: removeDocAccess } = useDeleteDocAccessRequest({ + onError: () => { + toast(t('Error while removing the request.'), VariantType.ERROR, { + duration: 4000, + }); + }, + }); + + if (!doc.abilities.accesses_view) { + return null; + } + + return ( + + + + + + {doc.abilities.accesses_manage && ( + + removeDocAccess({ + accessRequestId: accessRequest.id, + docId: doc.id, + }) + } + > + + + )} + + } + /> + + ); +}; + +interface QuickSearchGroupAccessRequestProps { + doc: Doc; +} + +export const QuickSearchGroupAccessRequest = ({ + doc, +}: QuickSearchGroupAccessRequestProps) => { + const { t } = useTranslation(); + const accessRequestQuery = useDocAccessRequestsInfinite({ docId: doc.id }); + + const accessRequestsData: QuickSearchData = useMemo(() => { + const accessRequests = + accessRequestQuery.data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: t('Access Requests'), + elements: accessRequests, + endActions: accessRequestQuery.hasNextPage + ? [ + { + content: , + onSelect: () => void accessRequestQuery.fetchNextPage(), + }, + ] + : undefined, + }; + }, [accessRequestQuery, t]); + + if (!accessRequestsData.elements.length) { + return null; + } + + return ( + + + ( + + )} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index ebd3f3f2..a207010e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -17,6 +17,7 @@ import { isValidEmail } from '@/utils'; import { KEY_LIST_USER, useUsers } from '../api'; +import { QuickSearchGroupAccessRequest } from './DocShareAccessRequest'; import { DocShareAddMemberList } from './DocShareAddMemberList'; import { DocShareModalInviteUserRow, @@ -26,6 +27,9 @@ import { QuickSearchGroupMember } from './DocShareMember'; import { DocShareModalFooter } from './DocShareModalFooter'; const ShareModalStyle = createGlobalStyle` + .--docs--doc-share-modal [cmdk-item] { + cursor: auto; + } .c__modal__title { padding-bottom: 0 !important; } @@ -173,6 +177,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => { > {showMemberSection ? ( <> +