Skip to content

Commit cce4870

Browse files
committed
🛂(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.
1 parent b8b9b8d commit cce4870

File tree

6 files changed

+268
-10
lines changed

6 files changed

+268
-10
lines changed

src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
toggleHeaderMenu,
99
verifyDocName,
1010
} from './utils-common';
11-
import { addNewMember } from './utils-share';
11+
import { writeInEditor } from './utils-editor';
12+
import {
13+
addNewMember,
14+
connectOtherUserToDoc,
15+
updateShareLink,
16+
} from './utils-share';
1217
import { createRootSubPage } from './utils-sub-pages';
1318

1419
test.describe('Doc grid move', () => {
@@ -242,6 +247,137 @@ test.describe('Doc grid move', () => {
242247
const docTree = page.getByTestId('doc-tree');
243248
await expect(docTree.getByText(titleDoc1)).toBeVisible();
244249
});
250+
251+
test('it proposes an access request when moving a doc without sufficient permissions', async ({
252+
page,
253+
browserName,
254+
}) => {
255+
test.slow();
256+
await page.goto('/');
257+
258+
const [titleDoc1] = await createDoc(page, 'Move doc', browserName, 1);
259+
260+
const { otherPage, cleanup } = await connectOtherUserToDoc({
261+
docUrl: '/',
262+
browserName,
263+
});
264+
265+
// Another user creates a doc
266+
const [titleDoc2] = await createDoc(otherPage, 'Drop doc', browserName, 1);
267+
await writeInEditor({
268+
page: otherPage,
269+
text: 'Hello world',
270+
});
271+
// Make it public
272+
await otherPage.getByRole('button', { name: 'Share' }).click();
273+
await updateShareLink(otherPage, 'Public');
274+
await otherPage
275+
.getByRole('dialog')
276+
.getByRole('button', { name: 'close' })
277+
.click();
278+
const otherPageUrl = otherPage.url();
279+
280+
// The first user visit the doc to have it in his grid list
281+
await page.goto(otherPageUrl);
282+
await expect(page.getByText('Hello world')).toBeVisible();
283+
284+
await page.waitForTimeout(1000);
285+
286+
await page.getByRole('button', { name: 'Back to homepage' }).click();
287+
288+
const docsGrid = page.getByTestId('docs-grid');
289+
await expect(docsGrid.getByText(titleDoc1)).toBeVisible();
290+
await expect(docsGrid.getByText(titleDoc2)).toBeVisible();
291+
292+
const row = await getGridRow(page, titleDoc1);
293+
await row.getByText(`more_horiz`).click();
294+
295+
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
296+
297+
await expect(
298+
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
299+
).toBeVisible();
300+
301+
const input = page.getByRole('combobox', { name: 'Quick search input' });
302+
await input.click();
303+
await input.fill(titleDoc2);
304+
305+
await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible();
306+
307+
// Select the first result
308+
await page.keyboard.press('Enter');
309+
// The CTA should get the focus
310+
await page.keyboard.press('Tab');
311+
// Validate the move action
312+
await page.keyboard.press('Enter');
313+
314+
// Request access modal should be visible
315+
await expect(
316+
page
317+
.getByRole('dialog')
318+
.getByText(
319+
'You need edit access to the destination. Request access, then try again.',
320+
),
321+
).toBeVisible();
322+
323+
await page
324+
.getByRole('dialog')
325+
.getByRole('button', { name: 'Request access', exact: true })
326+
.first()
327+
.click();
328+
329+
// The other user should receive the access request and be able to approve it
330+
await otherPage.getByRole('button', { name: 'Share' }).click();
331+
await expect(otherPage.getByText('Access Requests')).toBeVisible();
332+
await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible();
333+
334+
const emailRequest = `user.test@${browserName}.test`;
335+
await expect(otherPage.getByText(emailRequest)).toBeVisible();
336+
const container = otherPage.getByTestId(
337+
`doc-share-access-request-row-${emailRequest}`,
338+
);
339+
await container.getByTestId('doc-role-dropdown').click();
340+
await otherPage.getByRole('menuitem', { name: 'Administrator' }).click();
341+
await container.getByRole('button', { name: 'Approve' }).click();
342+
343+
await expect(otherPage.getByText('Access Requests')).toBeHidden();
344+
await expect(otherPage.getByText('Share with 2 users')).toBeVisible();
345+
await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible();
346+
347+
// The first user should now be able to move the doc
348+
await page.reload();
349+
await row.getByText(`more_horiz`).click();
350+
351+
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
352+
353+
await expect(
354+
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
355+
).toBeVisible();
356+
357+
await input.click();
358+
await input.fill(titleDoc2);
359+
360+
await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible();
361+
362+
// Select the first result
363+
await page.keyboard.press('Enter');
364+
// The CTA should get the focus
365+
await page.keyboard.press('Tab');
366+
// Validate the move action
367+
await page.keyboard.press('Enter');
368+
369+
await expect(docsGrid.getByText(titleDoc1)).toBeHidden();
370+
await docsGrid
371+
.getByRole('link', { name: `Open document ${titleDoc2}` })
372+
.click();
373+
374+
await verifyDocName(page, titleDoc2);
375+
376+
const docTree = page.getByTestId('doc-tree');
377+
await expect(docTree.getByText(titleDoc1)).toBeVisible();
378+
379+
await cleanup();
380+
});
245381
});
246382

247383
test.describe('Doc grid dnd mobile', () => {

src/frontend/apps/impress/src/components/modal/AlertModal.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
1+
import {
2+
Button,
3+
ButtonProps,
4+
Modal,
5+
ModalProps,
6+
ModalSize,
7+
} from '@gouvfr-lasuite/cunningham-react';
28
import { ReactNode } from 'react';
39
import { useTranslation } from 'react-i18next';
410

@@ -10,10 +16,11 @@ export type AlertModalProps = {
1016
isOpen: boolean;
1117
onClose: () => void;
1218
onConfirm: () => void;
19+
themeCTA?: ButtonProps['color'];
1320
title: string;
1421
cancelLabel?: string;
1522
confirmLabel?: string;
16-
};
23+
} & Partial<ModalProps>;
1724

1825
export const AlertModal = ({
1926
cancelLabel,
@@ -23,6 +30,8 @@ export const AlertModal = ({
2330
onClose,
2431
onConfirm,
2532
title,
33+
themeCTA,
34+
...props
2635
}: AlertModalProps) => {
2736
const { t } = useTranslation();
2837
return (
@@ -46,7 +55,7 @@ export const AlertModal = ({
4655
<Box $direction="row-reverse" $gap="small">
4756
<Button
4857
aria-label={confirmLabel ?? t('Confirm')}
49-
color="error"
58+
color={themeCTA ?? 'error'}
5059
onClick={onConfirm}
5160
>
5261
{confirmLabel ?? t('Confirm')}
@@ -61,6 +70,7 @@ export const AlertModal = ({
6170
</Button>
6271
</Box>
6372
}
73+
{...props}
6474
>
6575
<Box className="--docs--alert-modal">
6676
<Box>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Button } from '@gouvfr-lasuite/cunningham-react';
2+
import { Trans, useTranslation } from 'react-i18next';
3+
4+
import { AlertModal, Box, Icon, Text } from '@/components';
5+
6+
import { useDocAccessRequests } from '../api/useDocAccessRequest';
7+
8+
import { ButtonAccessRequest } from './DocShareAccessRequest';
9+
10+
interface AlertModalRequestAccessProps {
11+
docId: string;
12+
isOpen: boolean;
13+
onClose: () => void;
14+
onConfirm: () => void;
15+
targetDocumentTitle: string;
16+
title: string;
17+
}
18+
19+
export const AlertModalRequestAccess = ({
20+
docId,
21+
isOpen,
22+
onClose,
23+
onConfirm,
24+
targetDocumentTitle,
25+
title,
26+
}: AlertModalRequestAccessProps) => {
27+
const { t } = useTranslation();
28+
const { data: requests } = useDocAccessRequests({
29+
docId,
30+
page: 1,
31+
});
32+
33+
const hasRequested = !!(
34+
requests && requests?.results.find((request) => request.document === docId)
35+
);
36+
37+
return (
38+
<AlertModal
39+
onClose={onClose}
40+
isOpen={isOpen}
41+
title={title}
42+
description={
43+
<>
44+
<Text $display="inline">
45+
<Trans
46+
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."
47+
values={{
48+
targetDocumentTitle,
49+
}}
50+
components={{ strong: <strong /> }}
51+
/>
52+
</Text>
53+
{hasRequested && (
54+
<Text
55+
$weight="bold"
56+
$margin={{ top: 'sm' }}
57+
$direction="row"
58+
$align="center"
59+
>
60+
<Icon
61+
iconName="person_check"
62+
$margin={{ right: 'xxs' }}
63+
variant="symbols-outlined"
64+
/>
65+
{t('You have already requested access to this document.')}
66+
</Text>
67+
)}
68+
</>
69+
}
70+
confirmLabel={t('Request access')}
71+
onConfirm={onConfirm}
72+
rightActions={
73+
<Box $direction="row-reverse" $gap="small">
74+
<ButtonAccessRequest docId={docId} onClick={onConfirm} />
75+
<Button
76+
aria-label={t('Cancel')}
77+
variant="secondary"
78+
fullWidth
79+
onClick={onClose}
80+
>
81+
{t('Cancel')}
82+
</Button>
83+
</Box>
84+
}
85+
/>
86+
);
87+
};

src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
VariantType,
55
useToastProvider,
66
} from '@gouvfr-lasuite/cunningham-react';
7-
import { useMemo, useState } from 'react';
7+
import { MouseEventHandler, useMemo, useState } from 'react';
88
import { useTranslation } from 'react-i18next';
99
import { createGlobalStyle } from 'styled-components';
1010

@@ -167,10 +167,13 @@ export const QuickSearchGroupAccessRequest = ({
167167

168168
type ButtonAccessRequestProps = {
169169
docId: Doc['id'];
170-
} & ButtonProps;
170+
} & Omit<ButtonProps, 'onClick'> & {
171+
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
172+
};
171173

172174
export const ButtonAccessRequest = ({
173175
docId,
176+
onClick,
174177
...buttonProps
175178
}: ButtonAccessRequestProps) => {
176179
const { authenticated } = useAuth();
@@ -216,7 +219,10 @@ export const ButtonAccessRequest = ({
216219

217220
return (
218221
<Button
219-
onClick={() => createRequest({ docId })}
222+
onClick={(e) => {
223+
createRequest({ docId });
224+
onClick?.(e);
225+
}}
220226
disabled={hasRequested}
221227
{...buttonProps}
222228
>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './AlertModalRequestAccess';
12
export * from './DocShareModal';
23
export * from './DocShareAccessRequest';

src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { QuickSearch } from '@/components/quick-search';
1616
import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management';
1717
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
1818
import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.png';
19+
import { AlertModalRequestAccess } from '@/docs/doc-share';
1920
import { useResponsiveStore } from '@/stores';
2021

2122
import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem';
@@ -75,6 +76,7 @@ export const DocMoveModal = ({
7576
const docTitle = doc.title || untitledDocument;
7677
const docTargetTitle = docSelected?.title || untitledDocument;
7778
const modalConfirmation = useModal();
79+
const modalRequest = useModal();
7880
const { mutate: moveDoc } = useMoveDoc(true);
7981
const [search, setSearch] = useState('');
8082
const { isDesktop } = useResponsiveStore();
@@ -113,6 +115,11 @@ export const DocMoveModal = ({
113115
variant="primary"
114116
fullWidth
115117
onClick={() => {
118+
if (!docSelected?.abilities.move) {
119+
modalRequest.open();
120+
return;
121+
}
122+
116123
if (doc.nb_accesses_direct > 1) {
117124
modalConfirmation.open();
118125
return;
@@ -210,9 +217,7 @@ export const DocMoveModal = ({
210217
<DocSearchContent
211218
search={search}
212219
filters={{ target: DocSearchTarget.ALL }}
213-
filterResults={(docResults) =>
214-
docResults.id !== doc.id && docResults.abilities.move
215-
}
220+
filterResults={(docResults) => docResults.id !== doc.id}
216221
onSelect={handleSelect}
217222
onLoadingChange={setLoading}
218223
renderSearchElement={(docSearch) => {
@@ -274,6 +279,19 @@ export const DocMoveModal = ({
274279
targetDocumentTitle={docTargetTitle}
275280
/>
276281
)}
282+
{modalRequest.isOpen && docSelected?.id && (
283+
<AlertModalRequestAccess
284+
docId={docSelected.id}
285+
isOpen={modalRequest.isOpen}
286+
onClose={modalRequest.onClose}
287+
onConfirm={() => {
288+
modalRequest.onClose();
289+
onClose();
290+
}}
291+
targetDocumentTitle={docTargetTitle}
292+
title={t('Move document')}
293+
/>
294+
)}
277295
</>
278296
);
279297
};

0 commit comments

Comments
 (0)