From 3c8cacc0480777ab004dd34518b2cdc2da587117 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 6 May 2025 14:25:10 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82(frontend)=20block=20edition=20to?= =?UTF-8?q?=20not=20connected=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If an editor is working on a shared document but is not connected to the collaborative server we are now blocking the edition. It is to avoid none connected users to overwrite the document with connected users. --- CHANGELOG.md | 1 + .../apps/e2e/__tests__/app-impress/common.ts | 2 +- .../__tests__/app-impress/doc-editor.spec.ts | 53 +++++++- .../doc-editor/components/BlockNoteEditor.tsx | 6 +- .../docs/doc-editor/components/DocEditor.tsx | 1 - .../doc-header/components/AlertNetwork.tsx | 113 ++++++++++++++++++ .../doc-header/components/AlertPublic.tsx | 39 ++++++ .../docs/doc-header/components/DocHeader.tsx | 58 ++++----- .../docs/doc-management/hooks/index.ts | 3 +- .../hooks/useIsCollaborativeEditable.tsx | 41 +++++++ .../stores/useProviderStore.tsx | 9 +- 11 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acabdb3..3b611656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - 🚩(frontend) version MIT only #911 - ✨(backend) integrate maleware_detection from django-lasuite #936 - 🩺(CI) add lint spell mistakes #954 +- 🛂(frontend) block edition to not connected users #945 ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 8217dc1b..09e1ce74 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -102,7 +102,7 @@ export const verifyDocName = async (page: Page, docName: string) => { export const addNewMember = async ( page: Page, index: number, - role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader', + role: 'Administrator' | 'Owner' | 'Editor' | 'Reader', fillText: string = 'user ', ) => { const responsePromiseSearchUser = page.waitForResponse( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d1a8d856..87d13c80 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -4,6 +4,8 @@ import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; import { + CONFIG, + addNewMember, createDoc, goToGridDoc, mockedDocument, @@ -363,7 +365,7 @@ test.describe('Doc Editor', () => { partial_update: true, retrieve: true, }, - link_reach: 'public', + link_reach: 'restricted', link_role: 'editor', created_at: '2021-09-01T09:00:00Z', title: '', @@ -453,6 +455,55 @@ test.describe('Doc Editor', () => { expect(svgBuffer.toString()).toContain('Hello svg'); }); + test('it checks block editing when not connected to collab server', async ({ + page, + }) => { + await page.route('**/api/v1.0/config/', async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + ...CONFIG, + COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto('/'); + + void page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + const card = page.getByLabel('It is the card information'); + await expect( + card.getByText('Your network do not allow you to edit'), + ).toBeHidden(); + const editor = page.locator('.ProseMirror'); + + await expect(editor).toHaveAttribute('contenteditable', 'true'); + + await page.getByRole('button', { name: 'Share' }).click(); + + await addNewMember(page, 0, 'Editor', 'impress'); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + await expect( + card.getByText('Your network do not allow you to edit'), + ).toBeVisible({ + timeout: 10000, + }); + + await expect(editor).toHaveAttribute('contenteditable', 'false'); + }); + test('it checks if callout custom block', async ({ page, browserName }) => { await createDoc(page, 'doc-toolbar', browserName, 1); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index a927578c..e4240a02 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; -import { Doc } from '@/docs/doc-management'; +import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; import { useUploadFile } from '../hook'; @@ -49,7 +49,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { setEditor } = useEditorStore(); const { t } = useTranslation(); - const readOnly = !doc.abilities.partial_update; + const { isEditable, isLoading } = useIsCollaborativeEditable(doc); + const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; + useSaveDoc(doc.id, provider.document, !readOnly); const { i18n } = useTranslation(); const lang = i18n.resolvedLanguage; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index d7e23aa5..6f07096e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -25,7 +25,6 @@ interface DocEditorProps { export const DocEditor = ({ doc, versionId }: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); - const isVersion = !!versionId && typeof versionId === 'string'; const { colorsTokens } = useCunninghamTheme(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx new file mode 100644 index 00000000..ac230310 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx @@ -0,0 +1,113 @@ +import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +export const AlertNetwork = () => { + const { t } = useTranslation(); + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + + + + + {t('Your network do not allow you to edit')} + + + setIsModalOpen(true)} + > + + + {t('Know more')} + + + + + {isModalOpen && ( + setIsModalOpen(false)} /> + )} + + ); +}; + +interface AlertNetworkModalProps { + onClose: () => void; +} + +export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { + return ( + onClose()} + rightActions={ + <> + + + } + size={ModalSize.MEDIUM} + title={ + + {t("Why can't I edit?")} + + } + > + + + {t( + 'The network configuration of your workstation or internet connection does not allow editing shared documents.', + )} + + + {t( + 'Docs use WebSockets to enable real-time editing. These communication channels allow instant and bidirectional exchanges between your browser and our servers. To access collaborative editing, please contact your IT department to enable WebSockets.', + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx new file mode 100644 index 00000000..de01fa57 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +export const AlertPublic = ({ isPublicDoc }: { isPublicDoc: boolean }) => { + const { t } = useTranslation(); + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + + return ( + + + + {isPublicDoc + ? t('Public document') + : t('Document accessible to any connected person')} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 8aebcd17..cae72309 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -1,17 +1,20 @@ import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; -import { Box, HorizontalSeparator, Icon, Text } from '@/components'; +import { Box, HorizontalSeparator, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, LinkReach, + Role, currentDocRole, + useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; +import { AlertNetwork } from './AlertNetwork'; +import { AlertPublic } from './AlertPublic'; import { DocTitle } from './DocTitle'; import { DocToolBox } from './DocToolBox'; @@ -20,51 +23,26 @@ interface DocHeaderProps { } export const DocHeader = ({ doc }: DocHeaderProps) => { - const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); - const { t } = useTranslation(); + const { transRole } = useTrans(); + const { isEditable } = useIsCollaborativeEditable(doc); const docIsPublic = doc.link_reach === LinkReach.PUBLIC; const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; - const { transRole } = useTrans(); - return ( <> + {!isEditable && } {(docIsPublic || docIsAuth) && ( - - - - {docIsPublic - ? t('Public document') - : t('Document accessible to any connected person')} - - + )} { {isDesktop && ( <> - - {transRole(currentDocRole(doc.abilities))} Â·  + + {transRole( + isEditable + ? currentDocRole(doc.abilities) + : Role.READER, + )} +  Â·  {t('Last update: {{update}}', { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index a1937ba0..96968e38 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useCollaboration'; -export * from './useTrans'; export * from './useCopyDocLink'; +export * from './useIsCollaborativeEditable'; +export * from './useTrans'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx new file mode 100644 index 00000000..aebf2bf1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; + +import { useProviderStore } from '../stores'; +import { Doc, LinkReach } from '../types'; + +export const useIsCollaborativeEditable = (doc: Doc) => { + const { isConnected } = useProviderStore(); + + const docIsPublic = doc.link_reach === LinkReach.PUBLIC; + const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; + const docHasMember = doc.nb_accesses_direct > 1; + const isShared = docIsPublic || docIsAuth || docHasMember; + const [isEditable, setIsEditable] = useState(true); + const [isLoading, setIsLoading] = useState(true); + + /** + * Connection can take a few seconds + */ + useEffect(() => { + const _isEditable = isConnected || !isShared; + setIsLoading(true); + + if (_isEditable) { + setIsEditable(true); + setIsLoading(false); + return; + } + + const timer = setTimeout(() => { + setIsEditable(false); + setIsLoading(false); + }, 5000); + + return () => clearTimeout(timer); + }, [isConnected, isShared]); + + return { + isEditable, + isLoading, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx index fb78eb6e..ae7ed151 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx @@ -1,4 +1,4 @@ -import { HocuspocusProvider } from '@hocuspocus/provider'; +import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider'; import * as Y from 'yjs'; import { create } from 'zustand'; @@ -12,10 +12,12 @@ export interface UseCollaborationStore { ) => HocuspocusProvider; destroyProvider: () => void; provider: HocuspocusProvider | undefined; + isConnected: boolean; } const defaultValues = { provider: undefined, + isConnected: false, }; export const useProviderStore = create((set, get) => ({ @@ -33,6 +35,11 @@ export const useProviderStore = create((set, get) => ({ url: wsUrl, name: storeId, document: doc, + onStatus: ({ status }) => { + set({ + isConnected: status === WebSocketStatus.Connected, + }); + }, }); set({