From 878de08b1ea4c150b2d089a661a6771920955388 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 19 Jun 2025 22:08:18 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20doc=20access?= =?UTF-8?q?=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user is redirected on the 403 page, they can now request access to the document. --- src/frontend/apps/impress/cunningham.ts | 10 ++ .../{LoadMoreText.tsx => Loading.tsx} | 7 + .../apps/impress/src/components/index.ts | 2 +- .../src/cunningham/cunningham-tokens.css | 5 +- .../src/cunningham/cunningham-tokens.ts | 3 +- .../features/docs/doc-management/types.tsx | 15 ++ .../doc-share/api/useDocAccessRequest.tsx | 102 +++++++++++ .../features/service-worker/service-worker.ts | 1 - src/frontend/apps/impress/src/pages/403.tsx | 68 -------- .../apps/impress/src/pages/docs/[id]/403.tsx | 161 ++++++++++++++++++ .../impress/src/pages/docs/[id]/index.tsx | 21 +-- 11 files changed, 310 insertions(+), 85 deletions(-) rename src/frontend/apps/impress/src/components/{LoadMoreText.tsx => Loading.tsx} (81%) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx delete mode 100644 src/frontend/apps/impress/src/pages/403.tsx create mode 100644 src/frontend/apps/impress/src/pages/docs/[id]/403.tsx diff --git a/src/frontend/apps/impress/cunningham.ts b/src/frontend/apps/impress/cunningham.ts index 135370f9..43c337f3 100644 --- a/src/frontend/apps/impress/cunningham.ts +++ b/src/frontend/apps/impress/cunningham.ts @@ -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)', + }, + }, }, }; diff --git a/src/frontend/apps/impress/src/components/LoadMoreText.tsx b/src/frontend/apps/impress/src/components/Loading.tsx similarity index 81% rename from src/frontend/apps/impress/src/components/LoadMoreText.tsx rename to src/frontend/apps/impress/src/components/Loading.tsx index 4caa811a..ec688b48 100644 --- a/src/frontend/apps/impress/src/components/LoadMoreText.tsx +++ b/src/frontend/apps/impress/src/components/Loading.tsx @@ -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 = ({ ); }; + +export const Loading = () => ( + + + +); diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index 205b7224..c1a1314f 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -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'; diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css index b1b9df6a..4ab56fa3 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css @@ -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 diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts index 8696aa2f..5b5df690 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts @@ -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', diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index e57dc6e1..2cc2b6c1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -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; + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx new file mode 100644 index 00000000..062edba6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx @@ -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 => { + 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({ + 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; + +interface GetDocAccessRequestsParams { + docId: Doc['id']; +} + +export const getDocAccessRequests = async ({ + docId, +}: GetDocAccessRequestsParams): Promise => { + 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; +}; + +export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests'; + +export function useDocAccessRequests( + params: GetDocAccessRequestsParams, + queryConfig?: UseQueryOptions< + AccessRequestResponse, + APIError, + AccessRequestResponse + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS, params], + queryFn: () => getDocAccessRequests(params), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts index 957de160..38701044 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts @@ -110,7 +110,6 @@ const precacheResources = [ '/index.html', '/401/', '/404/', - '/403/', FALLBACK.offline, FALLBACK.images, FALLBACK.docs, diff --git a/src/frontend/apps/impress/src/pages/403.tsx b/src/frontend/apps/impress/src/pages/403.tsx deleted file mode 100644 index dc5a0fbf..00000000 --- a/src/frontend/apps/impress/src/pages/403.tsx +++ /dev/null @@ -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 ( - <> - - - {t('Access Denied - Error 403')} - {t('Docs')} - - - - - {t('Image - - - - {t('You do not have permission to view this document.')} - - - - }> - {t('Home')} - - - - - - ); -}; - -Page.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Page; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx new file mode 100644 index 00000000..3eb96173 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx @@ -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 ( + <> + + + + + + + + + ); +} + +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 ; + } + + if (isLoadingDoc || isLoadingRequest) { + return ; + } + + return ( + <> + + + {t('Access Denied - Error 403')} - {t('Docs')} + + + + + {t('Image + + + + {hasRequested + ? t('Your access request for this document is pending.') + : t('Insufficient access rights to view the document.')} + + + + + } + color="tertiary" + > + {t('Home')} + + + + + + + + ); +}; + +const Page: NextPageWithLayout = () => { + return null; +}; + +Page.getLayout = function getLayout() { + return ; +}; + +export default Page; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index da4c10ba..41931518 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -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 ( - - - - ); + return ; } return ( @@ -138,11 +137,7 @@ const DocPage = ({ id }: DocProps) => { } if (!doc) { - return ( - - - - ); + return ; } return (