✨(frontend) integrate doc access request
When a user is redirected on the 403 page, they can now request access to the document.
This commit is contained in:
committed by
Manuel Raynaud
parent
d33286019c
commit
878de08b1e
@@ -64,6 +64,16 @@ tokens.themes.default.components = {
|
||||
'png-light': '/assets/favicon-light.png',
|
||||
'png-dark': '/assets/favicon-dark.png',
|
||||
},
|
||||
button: {
|
||||
...tokens.themes.default.components.button,
|
||||
primary: {
|
||||
...tokens.themes.default.components.button.primary,
|
||||
...{
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
|
||||
},
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from './Box';
|
||||
@@ -34,3 +35,9 @@ export const LoadMoreText = ({
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const Loading = () => (
|
||||
<Box $align="center" $justify="center" $height="100%">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
@@ -6,7 +6,7 @@ export * from './DropdownMenu';
|
||||
export * from './Icon';
|
||||
export * from './InfiniteScroll';
|
||||
export * from './Link';
|
||||
export * from './LoadMoreText';
|
||||
export * from './Loading';
|
||||
export * from './SideModal';
|
||||
export * from './separators';
|
||||
export * from './Text';
|
||||
|
||||
@@ -218,7 +218,10 @@
|
||||
--c--components--button--primary--color-active: #fff;
|
||||
--c--components--button--primary--color-focus-visible: #fff;
|
||||
--c--components--button--primary--disabled: var(
|
||||
--c--theme--colors--greyscale-500
|
||||
--c--theme--colors--greyscale-400
|
||||
);
|
||||
--c--components--button--primary--background--disabled: var(
|
||||
--c--theme--colors--greyscale-100
|
||||
);
|
||||
--c--components--button--primary-text--background--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
|
||||
@@ -229,7 +229,8 @@ export const tokens = {
|
||||
'color-hover': '#fff',
|
||||
'color-active': '#fff',
|
||||
'color-focus-visible': '#fff',
|
||||
disabled: '#7C7C7C',
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
|
||||
},
|
||||
'primary-text': {
|
||||
'background--color': '#000091',
|
||||
|
||||
@@ -76,3 +76,18 @@ export enum DocDefaultFilter {
|
||||
MY_DOCS = 'my_docs',
|
||||
SHARED_WITH_ME = 'shared_with_me',
|
||||
}
|
||||
|
||||
export interface AccessRequest {
|
||||
id: string;
|
||||
document: string;
|
||||
user: User;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
abilities: {
|
||||
destroy: boolean;
|
||||
update: boolean;
|
||||
partial_update: boolean;
|
||||
retrieve: boolean;
|
||||
accept: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { AccessRequest, Doc, Role } from '@/docs/doc-management';
|
||||
|
||||
import { OptionType } from '../types';
|
||||
|
||||
interface CreateDocAccessRequestParams {
|
||||
docId: Doc['id'];
|
||||
role?: Role;
|
||||
}
|
||||
|
||||
export const createDocAccessRequest = async ({
|
||||
docId,
|
||||
role,
|
||||
}: CreateDocAccessRequestParams): Promise<null> => {
|
||||
const response = await fetchAPI(`documents/${docId}/ask-for-access/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Failed to create a request to access to the doc.`,
|
||||
await errorCauses(response, {
|
||||
type: OptionType.NEW_MEMBER,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type UseCreateDocAccessRequestOptions = UseMutationOptions<
|
||||
null,
|
||||
APIError,
|
||||
CreateDocAccessRequestParams
|
||||
>;
|
||||
|
||||
export function useCreateDocAccessRequest(
|
||||
options?: UseCreateDocAccessRequestOptions,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<null, APIError, CreateDocAccessRequestParams>({
|
||||
mutationFn: createDocAccessRequest,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
|
||||
});
|
||||
|
||||
void options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type AccessRequestResponse = APIList<AccessRequest>;
|
||||
|
||||
interface GetDocAccessRequestsParams {
|
||||
docId: Doc['id'];
|
||||
}
|
||||
|
||||
export const getDocAccessRequests = async ({
|
||||
docId,
|
||||
}: GetDocAccessRequestsParams): Promise<AccessRequestResponse> => {
|
||||
const response = await fetchAPI(`documents/${docId}/ask-for-access/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc access requests',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<AccessRequestResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests';
|
||||
|
||||
export function useDocAccessRequests(
|
||||
params: GetDocAccessRequestsParams,
|
||||
queryConfig?: UseQueryOptions<
|
||||
AccessRequestResponse,
|
||||
APIError,
|
||||
AccessRequestResponse
|
||||
>,
|
||||
) {
|
||||
return useQuery<AccessRequestResponse, APIError, AccessRequestResponse>({
|
||||
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS, params],
|
||||
queryFn: () => getDocAccessRequests(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -110,7 +110,6 @@ const precacheResources = [
|
||||
'/index.html',
|
||||
'/401/',
|
||||
'/404/',
|
||||
'/403/',
|
||||
FALLBACK.offline,
|
||||
FALLBACK.images,
|
||||
FALLBACK.docs,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import img403 from '@/assets/icons/icon-403.png';
|
||||
import { Box, Icon, StyledLink, Text } from '@/components';
|
||||
import { PageLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{t('Access Denied - Error 403')} - {t('Docs')}
|
||||
</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${t('Access Denied - Error 403')} - ${t('Docs')}`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
<Box
|
||||
$align="center"
|
||||
$margin="auto"
|
||||
$gap="1rem"
|
||||
$padding={{ bottom: '2rem' }}
|
||||
>
|
||||
<Image
|
||||
className="c__image-system-filter"
|
||||
src={img403}
|
||||
alt={t('Image 403')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box $align="center" $gap="0.8rem">
|
||||
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
|
||||
{t('You do not have permission to view this document.')}
|
||||
</Text>
|
||||
|
||||
<StyledLink href="/">
|
||||
<StyledButton icon={<Icon iconName="house" $color="white" />}>
|
||||
{t('Home')}
|
||||
</StyledButton>
|
||||
</StyledLink>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <PageLayout withFooter={false}>{page}</PageLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
161
src/frontend/apps/impress/src/pages/docs/[id]/403.tsx
Normal file
161
src/frontend/apps/impress/src/pages/docs/[id]/403.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import img403 from '@/assets/icons/icon-403.png';
|
||||
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
||||
import { KEY_DOC, useDoc } from '@/features/docs';
|
||||
import {
|
||||
useCreateDocAccessRequest,
|
||||
useDocAccessRequests,
|
||||
} from '@/features/docs/doc-share/api/useDocAccessRequest';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
export function DocLayout() {
|
||||
const {
|
||||
query: { id },
|
||||
} = useRouter();
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="robots" content="noindex" />
|
||||
</Head>
|
||||
|
||||
<MainLayout>
|
||||
<DocPage403 id={id} />
|
||||
</MainLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const DocPage403 = ({ id }: DocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: requests, isLoading: isLoadingRequest } = useDocAccessRequests({
|
||||
docId: id,
|
||||
});
|
||||
const { replace } = useRouter();
|
||||
const { toast } = useToastProvider();
|
||||
const { mutate: createRequest } = useCreateDocAccessRequest({
|
||||
onSuccess: () => {
|
||||
toast(t('Access request sent successfully.'), VariantType.SUCCESS, {
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const hasRequested = !!requests?.results.find(
|
||||
(request) => request.document === id,
|
||||
);
|
||||
|
||||
const { error, isLoading: isLoadingDoc } = useDoc(
|
||||
{ id },
|
||||
{
|
||||
staleTime: 0,
|
||||
queryKey: [KEY_DOC, { id }],
|
||||
retry: (failureCount, error) => {
|
||||
if (error.status == 403) {
|
||||
return false;
|
||||
} else {
|
||||
return failureCount < DEFAULT_QUERY_RETRY;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error?.status !== 403) {
|
||||
void replace(`/docs/${id}`);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isLoadingDoc || isLoadingRequest) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{t('Access Denied - Error 403')} - {t('Docs')}
|
||||
</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${t('Access Denied - Error 403')} - ${t('Docs')}`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
<Box
|
||||
$align="center"
|
||||
$margin="auto"
|
||||
$gap="1rem"
|
||||
$padding={{ bottom: '2rem' }}
|
||||
>
|
||||
<Image
|
||||
className="c__image-system-filter"
|
||||
src={img403}
|
||||
alt={t('Image 403')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box $align="center" $gap="0.8rem">
|
||||
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
|
||||
{hasRequested
|
||||
? t('Your access request for this document is pending.')
|
||||
: t('Insufficient access rights to view the document.')}
|
||||
</Text>
|
||||
|
||||
<Box $direction="row" $gap="0.7rem">
|
||||
<StyledLink href="/">
|
||||
<StyledButton
|
||||
icon={<Icon iconName="house" $theme="primary" />}
|
||||
color="tertiary"
|
||||
>
|
||||
{t('Home')}
|
||||
</StyledButton>
|
||||
</StyledLink>
|
||||
<Button
|
||||
onClick={() => createRequest({ docId: id })}
|
||||
disabled={hasRequested}
|
||||
>
|
||||
{t('Request access')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout() {
|
||||
return <DocLayout />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, TextErrors } from '@/components';
|
||||
import { Box, Icon, Loading, TextErrors } from '@/components';
|
||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
||||
import { DocEditor } from '@/docs/doc-editor';
|
||||
import {
|
||||
@@ -104,6 +103,8 @@ const DocPage = ({ id }: DocProps) => {
|
||||
|
||||
if (isError && error) {
|
||||
if ([403, 404, 401].includes(error.status)) {
|
||||
let replacePath = `/${error.status}`;
|
||||
|
||||
if (error.status === 401) {
|
||||
if (authenticated) {
|
||||
queryClient.setQueryData([KEY_AUTH], {
|
||||
@@ -112,15 +113,13 @@ const DocPage = ({ id }: DocProps) => {
|
||||
});
|
||||
}
|
||||
setAuthUrl();
|
||||
} else if (error.status === 403) {
|
||||
replacePath = `/docs/${id}/403`;
|
||||
}
|
||||
|
||||
void replace(`/${error.status}`);
|
||||
void replace(replacePath);
|
||||
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="100%">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -138,11 +137,7 @@ const DocPage = ({ id }: DocProps) => {
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="100%">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user