🛂(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:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './AlertModalRequestAccess';
|
||||||
export * from './DocShareModal';
|
export * from './DocShareModal';
|
||||||
export * from './DocShareAccessRequest';
|
export * from './DocShareAccessRequest';
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user