From f24b047a7cc146411412bf759b5b5248a45c3d99 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 20 Feb 2026 21:32:57 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82(frontend)=20add=20access=20request?= =?UTF-8?q?=20modal=20on=20move=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a user tries to move a document for which they don't have the right to move, we now display a modal to request access to the owners of the document. --- .../app-impress/doc-grid-move.spec.ts | 138 +++++++++++++++++- .../src/components/modal/AlertModal.tsx | 16 +- .../components/AlertModalRequestAccess.tsx | 89 +++++++++++ .../components/DocShareAccessRequest.tsx | 12 +- .../docs/doc-share/components/index.ts | 1 + .../docs-grid/components/DocMoveModal.tsx | 24 ++- 6 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts index 822c1025..ae8c6fe4 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts @@ -8,7 +8,12 @@ import { toggleHeaderMenu, verifyDocName, } from './utils-common'; -import { addNewMember } from './utils-share'; +import { writeInEditor } from './utils-editor'; +import { + addNewMember, + connectOtherUserToDoc, + updateShareLink, +} from './utils-share'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Doc grid move', () => { @@ -242,6 +247,137 @@ test.describe('Doc grid move', () => { const docTree = page.getByTestId('doc-tree'); await expect(docTree.getByText(titleDoc1)).toBeVisible(); }); + + test('it proposes an access request when moving a doc without sufficient permissions', async ({ + page, + browserName, + }) => { + test.slow(); + await page.goto('/'); + + const [titleDoc1] = await createDoc(page, 'Move doc', browserName, 1); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + docUrl: '/', + browserName, + }); + + // Another user creates a doc + const [titleDoc2] = await createDoc(otherPage, 'Drop doc', browserName, 1); + await writeInEditor({ + page: otherPage, + text: 'Hello world', + }); + // Make it public + await otherPage.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(otherPage, 'Public'); + await otherPage + .getByRole('dialog') + .getByRole('button', { name: 'close' }) + .click(); + const otherPageUrl = otherPage.url(); + + // The first user visit the doc to have it in his grid list + await page.goto(otherPageUrl); + await expect(page.getByText('Hello world')).toBeVisible(); + + await page.waitForTimeout(1000); + + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid.getByText(titleDoc1)).toBeVisible(); + await expect(docsGrid.getByText(titleDoc2)).toBeVisible(); + + const row = await getGridRow(page, titleDoc1); + await row.getByText(`more_horiz`).click(); + + await page.getByRole('menuitem', { name: 'Move into a doc' }).click(); + + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Move' }), + ).toBeVisible(); + + const input = page.getByRole('combobox', { name: 'Quick search input' }); + await input.click(); + await input.fill(titleDoc2); + + await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible(); + + // Select the first result + await page.keyboard.press('Enter'); + // The CTA should get the focus + await page.keyboard.press('Tab'); + // Validate the move action + await page.keyboard.press('Enter'); + + // Request access modal should be visible + await expect( + page + .getByRole('dialog') + .getByText( + 'You need edit access to the destination. Request access, then try again.', + ), + ).toBeVisible(); + + await page + .getByRole('dialog') + .getByRole('button', { name: 'Request access', exact: true }) + .first() + .click(); + + // The other user should receive the access request and be able to approve it + await otherPage.getByRole('button', { name: 'Share' }).click(); + await expect(otherPage.getByText('Access Requests')).toBeVisible(); + await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible(); + + const emailRequest = `user.test@${browserName}.test`; + await expect(otherPage.getByText(emailRequest)).toBeVisible(); + const container = otherPage.getByTestId( + `doc-share-access-request-row-${emailRequest}`, + ); + await container.getByTestId('doc-role-dropdown').click(); + await otherPage.getByRole('menuitem', { name: 'Administrator' }).click(); + await container.getByRole('button', { name: 'Approve' }).click(); + + await expect(otherPage.getByText('Access Requests')).toBeHidden(); + await expect(otherPage.getByText('Share with 2 users')).toBeVisible(); + await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible(); + + // The first user should now be able to move the doc + await page.reload(); + await row.getByText(`more_horiz`).click(); + + await page.getByRole('menuitem', { name: 'Move into a doc' }).click(); + + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Move' }), + ).toBeVisible(); + + await input.click(); + await input.fill(titleDoc2); + + await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible(); + + // Select the first result + await page.keyboard.press('Enter'); + // The CTA should get the focus + await page.keyboard.press('Tab'); + // Validate the move action + await page.keyboard.press('Enter'); + + await expect(docsGrid.getByText(titleDoc1)).toBeHidden(); + await docsGrid + .getByRole('link', { name: `Open document ${titleDoc2}` }) + .click(); + + await verifyDocName(page, titleDoc2); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(titleDoc1)).toBeVisible(); + + await cleanup(); + }); }); test.describe('Doc grid dnd mobile', () => { diff --git a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx index 1b0f88c8..393b5ff6 100644 --- a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx +++ b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx @@ -1,4 +1,10 @@ -import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { + Button, + ButtonProps, + Modal, + ModalProps, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; import { ReactNode, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,10 +16,11 @@ export type AlertModalProps = { isOpen: boolean; onClose: () => void; onConfirm: () => void; + themeCTA?: ButtonProps['color']; title: string; cancelLabel?: string; confirmLabel?: string; -}; +} & Partial; export const AlertModal = ({ cancelLabel, @@ -23,6 +30,8 @@ export const AlertModal = ({ onClose, onConfirm, title, + themeCTA, + ...props }: AlertModalProps) => { const { t } = useTranslation(); @@ -71,13 +80,14 @@ export const AlertModal = ({ } + {...props} > diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx new file mode 100644 index 00000000..6060b0a6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx @@ -0,0 +1,89 @@ +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { AlertModal, Box, Icon, Text } from '@/components'; + +import { useDocAccessRequests } from '../api/useDocAccessRequest'; + +import { ButtonAccessRequest } from './DocShareAccessRequest'; + +interface AlertModalRequestAccessProps { + docId: string; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + targetDocumentTitle: string; + title: string; +} + +export const AlertModalRequestAccess = ({ + docId, + isOpen, + onClose, + onConfirm, + targetDocumentTitle, + title, +}: AlertModalRequestAccessProps) => { + const { t } = useTranslation(); + const { data: requests } = useDocAccessRequests({ + docId, + page: 1, + }); + + const hasRequested = !!( + requests && requests?.results.find((request) => request.document === docId) + ); + + return ( + + + }} + /> + + {hasRequested && ( + + + {t('You have already requested access to this document.')} + + )} + + } + confirmLabel={t('Request access')} + onConfirm={onConfirm} + rightActions={ + + + + + } + /> + ); +}; 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 index 898c99c2..c0ab9c46 100644 --- 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 @@ -4,7 +4,7 @@ import { VariantType, useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; -import { useMemo, useState } from 'react'; +import { MouseEventHandler, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createGlobalStyle } from 'styled-components'; @@ -167,10 +167,13 @@ export const QuickSearchGroupAccessRequest = ({ type ButtonAccessRequestProps = { docId: Doc['id']; -} & ButtonProps; +} & Omit & { + onClick?: MouseEventHandler; + }; export const ButtonAccessRequest = ({ docId, + onClick, ...buttonProps }: ButtonAccessRequestProps) => { const { authenticated } = useAuth(); @@ -216,7 +219,10 @@ export const ButtonAccessRequest = ({ return (