✨(frontend) add access request on doc share modal
Add the access request to the document share modal, allowing admin to see and manage access requests directly from the modal interface.
This commit is contained in:
committed by
Manuel Raynaud
parent
411d52c73b
commit
2360a832af
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<null> => {
|
||||
}: CreateDocAccessRequestParams): Promise<void> => {
|
||||
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<null, APIError, CreateDocAccessRequestParams>({
|
||||
return useMutation<void, APIError, CreateDocAccessRequestParams>({
|
||||
mutationFn: createDocAccessRequest,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
@@ -65,14 +71,21 @@ export function useCreateDocAccessRequest(
|
||||
|
||||
type AccessRequestResponse = APIList<AccessRequest>;
|
||||
|
||||
interface GetDocAccessRequestsParams {
|
||||
interface DocAccessRequestsParams {
|
||||
docId: Doc['id'];
|
||||
}
|
||||
|
||||
export type DocAccessRequestsAPIParams = DocAccessRequestsParams & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
export const getDocAccessRequests = async ({
|
||||
docId,
|
||||
}: GetDocAccessRequestsParams): Promise<AccessRequestResponse> => {
|
||||
const response = await fetchAPI(`documents/${docId}/ask-for-access/`);
|
||||
page,
|
||||
}: DocAccessRequestsAPIParams): Promise<AccessRequestResponse> => {
|
||||
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<void> => {
|
||||
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<AccessRequest>;
|
||||
|
||||
type UseAcceptDocAccessRequestsOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
UseAcceptDocAccessRequests
|
||||
>;
|
||||
|
||||
export const useAcceptDocAccessRequest = (
|
||||
options?: UseAcceptDocAccessRequestsOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, acceptDocAccessRequestsParams>({
|
||||
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<void> => {
|
||||
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<void, APIError, DeleteDocAccessRequestParams>({
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`doc-share-access-request-row-${accessRequest.user.email}`}
|
||||
className="--docs--doc-share-access-request-item"
|
||||
>
|
||||
<SearchUserRow
|
||||
alwaysShowRight={true}
|
||||
user={accessRequest.user}
|
||||
right={
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['sm']}>
|
||||
<DocRoleDropdown
|
||||
currentRole={role}
|
||||
onSelectRole={setRole}
|
||||
canUpdate={doc.abilities.accesses_manage}
|
||||
/>
|
||||
<Button
|
||||
color="tertiary"
|
||||
onClick={() =>
|
||||
acceptDocAccessRequests({
|
||||
docId: doc.id,
|
||||
accessRequestId: accessRequest.id,
|
||||
role,
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
{t('Approve')}
|
||||
</Button>
|
||||
|
||||
{doc.abilities.accesses_manage && (
|
||||
<BoxButton
|
||||
onClick={() =>
|
||||
removeDocAccess({
|
||||
accessRequestId: accessRequest.id,
|
||||
docId: doc.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon iconName="close" $variation="600" $size="16px" />
|
||||
</BoxButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuickSearchGroupAccessRequestProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const QuickSearchGroupAccessRequest = ({
|
||||
doc,
|
||||
}: QuickSearchGroupAccessRequestProps) => {
|
||||
const { t } = useTranslation();
|
||||
const accessRequestQuery = useDocAccessRequestsInfinite({ docId: doc.id });
|
||||
|
||||
const accessRequestsData: QuickSearchData<AccessRequest> = useMemo(() => {
|
||||
const accessRequests =
|
||||
accessRequestQuery.data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
return {
|
||||
groupName: t('Access Requests'),
|
||||
elements: accessRequests,
|
||||
endActions: accessRequestQuery.hasNextPage
|
||||
? [
|
||||
{
|
||||
content: <LoadMoreText data-testid="load-more-requests" />,
|
||||
onSelect: () => void accessRequestQuery.fetchNextPage(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}, [accessRequestQuery, t]);
|
||||
|
||||
if (!accessRequestsData.elements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
aria-label={t('List request access card')}
|
||||
className="--docs--share-access-request"
|
||||
>
|
||||
<QuickSearchGroupAccessRequestStyle />
|
||||
<QuickSearchGroup
|
||||
group={accessRequestsData}
|
||||
renderElement={(accessRequest) => (
|
||||
<DocShareAccessRequestItem doc={doc} accessRequest={accessRequest} />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<QuickSearchGroupAccessRequest doc={doc} />
|
||||
<QuickSearchGroupInvitation doc={doc} />
|
||||
<QuickSearchGroupMember doc={doc} />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user