✨(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-light': '/assets/favicon-light.png',
|
||||||
'png-dark': '/assets/favicon-dark.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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Box } from './Box';
|
import { Box } from './Box';
|
||||||
@@ -34,3 +35,9 @@ export const LoadMoreText = ({
|
|||||||
</Box>
|
</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 './Icon';
|
||||||
export * from './InfiniteScroll';
|
export * from './InfiniteScroll';
|
||||||
export * from './Link';
|
export * from './Link';
|
||||||
export * from './LoadMoreText';
|
export * from './Loading';
|
||||||
export * from './SideModal';
|
export * from './SideModal';
|
||||||
export * from './separators';
|
export * from './separators';
|
||||||
export * from './Text';
|
export * from './Text';
|
||||||
|
|||||||
@@ -218,7 +218,10 @@
|
|||||||
--c--components--button--primary--color-active: #fff;
|
--c--components--button--primary--color-active: #fff;
|
||||||
--c--components--button--primary--color-focus-visible: #fff;
|
--c--components--button--primary--color-focus-visible: #fff;
|
||||||
--c--components--button--primary--disabled: var(
|
--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--components--button--primary-text--background--color: var(
|
||||||
--c--theme--colors--primary-text
|
--c--theme--colors--primary-text
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ export const tokens = {
|
|||||||
'color-hover': '#fff',
|
'color-hover': '#fff',
|
||||||
'color-active': '#fff',
|
'color-active': '#fff',
|
||||||
'color-focus-visible': '#fff',
|
'color-focus-visible': '#fff',
|
||||||
disabled: '#7C7C7C',
|
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||||
|
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
|
||||||
},
|
},
|
||||||
'primary-text': {
|
'primary-text': {
|
||||||
'background--color': '#000091',
|
'background--color': '#000091',
|
||||||
|
|||||||
@@ -76,3 +76,18 @@ export enum DocDefaultFilter {
|
|||||||
MY_DOCS = 'my_docs',
|
MY_DOCS = 'my_docs',
|
||||||
SHARED_WITH_ME = 'shared_with_me',
|
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',
|
'/index.html',
|
||||||
'/401/',
|
'/401/',
|
||||||
'/404/',
|
'/404/',
|
||||||
'/403/',
|
|
||||||
FALLBACK.offline,
|
FALLBACK.offline,
|
||||||
FALLBACK.images,
|
FALLBACK.images,
|
||||||
FALLBACK.docs,
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { DEFAULT_QUERY_RETRY } from '@/core';
|
||||||
import { DocEditor } from '@/docs/doc-editor';
|
import { DocEditor } from '@/docs/doc-editor';
|
||||||
import {
|
import {
|
||||||
@@ -104,6 +103,8 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
|
|
||||||
if (isError && error) {
|
if (isError && error) {
|
||||||
if ([403, 404, 401].includes(error.status)) {
|
if ([403, 404, 401].includes(error.status)) {
|
||||||
|
let replacePath = `/${error.status}`;
|
||||||
|
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
queryClient.setQueryData([KEY_AUTH], {
|
queryClient.setQueryData([KEY_AUTH], {
|
||||||
@@ -112,15 +113,13 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setAuthUrl();
|
setAuthUrl();
|
||||||
|
} else if (error.status === 403) {
|
||||||
|
replacePath = `/docs/${id}/403`;
|
||||||
}
|
}
|
||||||
|
|
||||||
void replace(`/${error.status}`);
|
void replace(replacePath);
|
||||||
|
|
||||||
return (
|
return <Loading />;
|
||||||
<Box $align="center" $justify="center" $height="100%">
|
|
||||||
<Loader />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,11 +137,7 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return (
|
return <Loading />;
|
||||||
<Box $align="center" $justify="center" $height="100%">
|
|
||||||
<Loader />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user