🛂(frontend) add access request modal on move modal

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.
This commit is contained in:
Anthony LC
2026-02-20 21:32:57 +01:00
parent 3411df09ae
commit f24b047a7c
6 changed files with 270 additions and 10 deletions

View File

@@ -8,7 +8,12 @@ import {
toggleHeaderMenu, toggleHeaderMenu,
verifyDocName, verifyDocName,
} from './utils-common'; } 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'; import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid move', () => { test.describe('Doc grid move', () => {
@@ -242,6 +247,137 @@ test.describe('Doc grid move', () => {
const docTree = page.getByTestId('doc-tree'); const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(titleDoc1)).toBeVisible(); 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', () => { test.describe('Doc grid dnd mobile', () => {

View File

@@ -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 { ReactNode, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -10,10 +16,11 @@ export type AlertModalProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
themeCTA?: ButtonProps['color'];
title: string; title: string;
cancelLabel?: string; cancelLabel?: string;
confirmLabel?: string; confirmLabel?: string;
}; } & Partial<ModalProps>;
export const AlertModal = ({ export const AlertModal = ({
cancelLabel, cancelLabel,
@@ -23,6 +30,8 @@ export const AlertModal = ({
onClose, onClose,
onConfirm, onConfirm,
title, title,
themeCTA,
...props
}: AlertModalProps) => { }: AlertModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -71,13 +80,14 @@ export const AlertModal = ({
</Button> </Button>
<Button <Button
aria-label={confirmLabel ?? t('Confirm')} aria-label={confirmLabel ?? t('Confirm')}
color="error" color={themeCTA ?? 'error'}
onClick={onConfirm} onClick={onConfirm}
> >
{confirmLabel ?? t('Confirm')} {confirmLabel ?? t('Confirm')}
</Button> </Button>
</Box> </Box>
} }
{...props}
> >
<Box className="--docs--alert-modal"> <Box className="--docs--alert-modal">
<Box> <Box>

View File

@@ -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 (
<AlertModal
onClose={onClose}
isOpen={isOpen}
title={title}
aria-label={t('Request access modal')}
description={
<>
<Text $display="inline">
<Trans
i18nKey="You don't have permission to move this document to <strong>{{targetDocumentTitle}}</strong>. You need edit access to the destination. Request access, then try again."
values={{
targetDocumentTitle,
}}
components={{ strong: <strong /> }}
/>
</Text>
{hasRequested && (
<Text
$weight="bold"
$margin={{ top: 'sm' }}
$direction="row"
$align="center"
>
<Icon
iconName="person_check"
$margin={{ right: 'xxs' }}
variant="symbols-outlined"
/>
{t('You have already requested access to this document.')}
</Text>
)}
</>
}
confirmLabel={t('Request access')}
onConfirm={onConfirm}
rightActions={
<Box $direction="row" $gap="small">
<Button
aria-label={t('Cancel')}
variant="secondary"
fullWidth
onClick={onClose}
autoFocus
>
{t('Cancel')}
</Button>
<ButtonAccessRequest docId={docId} onClick={onConfirm} />
</Box>
}
/>
);
};

View File

@@ -4,7 +4,7 @@ import {
VariantType, VariantType,
useToastProvider, useToastProvider,
} from '@gouvfr-lasuite/cunningham-react'; } from '@gouvfr-lasuite/cunningham-react';
import { useMemo, useState } from 'react'; import { MouseEventHandler, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components'; import { createGlobalStyle } from 'styled-components';
@@ -167,10 +167,13 @@ export const QuickSearchGroupAccessRequest = ({
type ButtonAccessRequestProps = { type ButtonAccessRequestProps = {
docId: Doc['id']; docId: Doc['id'];
} & ButtonProps; } & Omit<ButtonProps, 'onClick'> & {
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
};
export const ButtonAccessRequest = ({ export const ButtonAccessRequest = ({
docId, docId,
onClick,
...buttonProps ...buttonProps
}: ButtonAccessRequestProps) => { }: ButtonAccessRequestProps) => {
const { authenticated } = useAuth(); const { authenticated } = useAuth();
@@ -216,7 +219,10 @@ export const ButtonAccessRequest = ({
return ( return (
<Button <Button
onClick={() => createRequest({ docId })} onClick={(e) => {
createRequest({ docId });
onClick?.(e);
}}
disabled={hasRequested} disabled={hasRequested}
{...buttonProps} {...buttonProps}
> >

View File

@@ -1,2 +1,3 @@
export * from './AlertModalRequestAccess';
export * from './DocShareModal'; export * from './DocShareModal';
export * from './DocShareAccessRequest'; export * from './DocShareAccessRequest';

View File

@@ -16,6 +16,7 @@ import { QuickSearch } from '@/components/quick-search';
import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management'; import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management';
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search'; import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.png'; import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.png';
import { AlertModalRequestAccess } from '@/docs/doc-share';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem'; import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem';
@@ -75,6 +76,7 @@ export const DocMoveModal = ({
const docTitle = doc.title || untitledDocument; const docTitle = doc.title || untitledDocument;
const docTargetTitle = docSelected?.title || untitledDocument; const docTargetTitle = docSelected?.title || untitledDocument;
const modalConfirmation = useModal(); const modalConfirmation = useModal();
const modalRequest = useModal();
const { mutate: moveDoc } = useMoveDoc(true); const { mutate: moveDoc } = useMoveDoc(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
@@ -114,6 +116,11 @@ export const DocMoveModal = ({
variant="primary" variant="primary"
fullWidth fullWidth
onClick={() => { onClick={() => {
if (!docSelected?.abilities.move) {
modalRequest.open();
return;
}
if (doc.nb_accesses_direct > 1) { if (doc.nb_accesses_direct > 1) {
modalConfirmation.open(); modalConfirmation.open();
return; return;
@@ -212,9 +219,7 @@ export const DocMoveModal = ({
<DocSearchContent <DocSearchContent
search={search} search={search}
filters={{ target: DocSearchTarget.ALL }} filters={{ target: DocSearchTarget.ALL }}
filterResults={(docResults) => filterResults={(docResults) => docResults.id !== doc.id}
docResults.id !== doc.id && docResults.abilities.move
}
onSelect={handleSelect} onSelect={handleSelect}
onLoadingChange={setLoading} onLoadingChange={setLoading}
renderSearchElement={(docSearch) => { renderSearchElement={(docSearch) => {
@@ -277,6 +282,19 @@ export const DocMoveModal = ({
targetDocumentTitle={docTargetTitle} targetDocumentTitle={docTargetTitle}
/> />
)} )}
{modalRequest.isOpen && docSelected?.id && (
<AlertModalRequestAccess
docId={docSelected.id}
isOpen={modalRequest.isOpen}
onClose={modalRequest.onClose}
onConfirm={() => {
modalRequest.onClose();
onClose();
}}
targetDocumentTitle={docTargetTitle}
title={t('Move document')}
/>
)}
</> </>
); );
}; };