(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:
Anthony LC
2025-06-19 22:08:18 +02:00
committed by Manuel Raynaud
parent d33286019c
commit 878de08b1e
11 changed files with 310 additions and 85 deletions

View File

@@ -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)',
},
},
},
};

View File

@@ -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>
);

View File

@@ -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';

View File

@@ -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

View File

@@ -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',

View File

@@ -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;
};
}

View File

@@ -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,
});
}

View File

@@ -110,7 +110,6 @@ const precacheResources = [
'/index.html',
'/401/',
'/404/',
'/403/',
FALLBACK.offline,
FALLBACK.images,
FALLBACK.docs,

View File

@@ -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;

View 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;

View File

@@ -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 (