🛂(frontend) block edition only when not alone

We added a system to know if a user is alone
on a document or not. We adapt the
frontend to block the edition only
when the user is not alone on the document.
This commit is contained in:
Anthony LC
2025-07-03 20:38:50 +02:00
committed by Manuel Raynaud
parent 9a8f952210
commit 55979e4370
9 changed files with 252 additions and 116 deletions

View File

@@ -1,14 +1,13 @@
import path from 'path'; import path from 'path';
import { expect, test } from '@playwright/test'; import { chromium, expect, test } from '@playwright/test';
import cs from 'convert-stream'; import cs from 'convert-stream';
import { import {
CONFIG,
addNewMember,
createDoc, createDoc,
goToGridDoc, goToGridDoc,
mockedDocument, mockedDocument,
overrideConfig,
verifyDocName, verifyDocName,
} from './common'; } from './common';
@@ -522,52 +521,141 @@ test.describe('Doc Editor', () => {
test('it checks block editing when not connected to collab server', async ({ test('it checks block editing when not connected to collab server', async ({
page, page,
browserName,
}) => { }) => {
await page.route('**/api/v1.0/config/', async (route) => { /**
const request = route.request(); * The good port is 4444, but we want to simulate a not connected
if (request.method().includes('GET')) { * collaborative server.
await route.fulfill({ * So we use a port that is not used by the collaborative server.
json: { * The server will not be able to connect to the collaborative server.
...CONFIG, */
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', await overrideConfig(page, {
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
},
});
} else {
await route.continue();
}
}); });
await page.goto('/'); await page.goto('/');
void page const [title] = await createDoc(page, 'editing-blocking', browserName, 1);
.getByRole('button', {
name: 'New doc',
})
.click();
const card = page.getByLabel('It is the card information'); const card = page.getByLabel('It is the card information');
await expect( await expect(
card.getByText('Your network do not allow you to edit'), card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden(); ).toBeHidden();
const editor = page.locator('.ProseMirror'); const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true'); await expect(editor).toHaveAttribute('contenteditable', 'true');
let responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', 'impress'); await page.getByLabel('Visibility', { exact: true }).click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
// Close the modal // Close the modal
await page.getByRole('button', { name: 'close' }).first().click(); await page.getByRole('button', { name: 'close' }).first().click();
let responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
const urlDoc = page.url();
/**
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
});
const otherPage = await otherContext.newPage();
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
},
);
await otherPage.goto(urlDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:4444/collaboration/ws/?room=',
);
await verifyDocName(otherPage, title);
await page.reload();
responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeFalsy();
await expect( await expect(
card.getByText('Your network do not allow you to edit'), card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({ ).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await expect(editor).toHaveAttribute('contenteditable', 'false'); await expect(editor).toHaveAttribute('contenteditable', 'false');
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility mode').click();
await page.getByRole('menuitem', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await page.reload();
responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
}); });
test('it checks if callout custom block', async ({ page, browserName }) => { test('it checks if callout custom block', async ({ page, browserName }) => {

View File

@@ -15,49 +15,11 @@ test.beforeEach(async ({ page }) => {
}); });
test.describe('Doc Header', () => { test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => { test('it checks the element are correctly displayed', async ({
await mockedDocument(page, { page,
accesses: [ browserName,
{ }) => {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', await createDoc(page, 'doc-update', browserName, 1);
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'public',
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
const card = page.getByLabel( const card = page.getByLabel(
'It is the card information about the document.', 'It is the card information about the document.',
@@ -66,6 +28,18 @@ test.describe('Doc Header', () => {
const docTitle = card.getByRole('textbox', { name: 'doc title input' }); const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible(); await expect(docTitle).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility', { exact: true }).click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await page.getByRole('button', { name: 'close' }).first().click();
await expect(card.getByText('Public document')).toBeVisible(); await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible(); await expect(card.getByText('Owner ·')).toBeVisible();

View File

@@ -50,9 +50,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isConnectedToCollabServer = provider.isSynced;
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
useSaveDoc(doc.id, provider.document, !readOnly); useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage; const lang = i18n.resolvedLanguage;

View File

@@ -41,7 +41,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true), { renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -73,7 +73,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, false), { renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -107,7 +107,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, true), { renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -143,7 +143,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, true), { renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -164,7 +164,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id'; const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });

View File

@@ -10,7 +10,12 @@ import { toBase64 } from '../utils';
const SAVE_INTERVAL = 60000; const SAVE_INTERVAL = 60000;
const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
canSave: boolean,
isConnectedToCollabServer: boolean,
) => {
const { mutate: updateDoc } = useUpdateDoc({ const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS], listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => { onSuccess: () => {
@@ -49,10 +54,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
updateDoc({ updateDoc({
id: docId, id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)), content: toBase64(Y.encodeStateAsUpdate(yDoc)),
websocket: isConnectedToCollabServer,
}); });
return true; return true;
}, [canSave, yDoc, docId, isLocalChange, updateDoc]); }, [
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
const router = useRouter(); const router = useRouter();

View File

@@ -32,7 +32,7 @@ export const AlertNetwork = () => {
<Box $direction="row" $gap={spacingsTokens['2xs']} $align="center"> <Box $direction="row" $gap={spacingsTokens['2xs']} $align="center">
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" /> <Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
<Text $theme="warning" $variation="600" $weight={500}> <Text $theme="warning" $variation="600" $weight={500}>
{t('Your network do not allow you to edit')} {t('Others are editing. Your network prevent changes.')}
</Text> </Text>
</Box> </Box>
<BoxButton <BoxButton
@@ -50,7 +50,7 @@ export const AlertNetwork = () => {
$margin={{ top: 'auto' }} $margin={{ top: 'auto' }}
/> />
<Text $theme="warning" $variation="600" $weight="500" $size="xs"> <Text $theme="warning" $variation="600" $weight="500" $size="xs">
{t('Know more')} {t('Learn more')}
</Text> </Text>
</BoxButton> </BoxButton>
</Box> </Box>
@@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
onClose={() => onClose()} onClose={() => onClose()}
rightActions={ rightActions={
<> <>
<Button aria-label={t('OK')} onClick={onClose}> <Button aria-label={t('OK')} onClick={onClose} color="danger">
{t('OK')} {t('I understand')}
</Button> </Button>
</> </>
} }
@@ -88,24 +88,39 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
$align="flex-start" $align="flex-start"
$variation="1000" $variation="1000"
> >
{t("Why can't I edit?")} {t("Why you can't edit the document?")}
</Text> </Text>
} }
> >
<Box <Box
aria-label={t('Content modal to explain why the user cannot edit')} aria-label={t('Content modal to explain why the user cannot edit')}
className="--docs--modal-alert-network" className="--docs--modal-alert-network"
$margin={{ top: 'xs' }} $margin={{ top: 'md' }}
> >
<Text $size="sm" $variation="600"> <Text $size="sm" $variation="600">
{t( {t(
'The network configuration of your workstation or internet connection does not allow editing shared documents.', 'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
)} )}
</Text> </Text>
<Text $size="sm" $variation="600" $margin={{ top: 'xs' }}> <Text
{t( $size="sm"
'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.', $variation="600"
)} $margin={{ top: 'xs' }}
$weight="bold"
$display="inline"
>
{t("This means you can't edit until others leave.")}{' '}
<Text
$size="sm"
$variation="600"
$margin={{ top: 'xs' }}
$weight="normal"
$display="inline"
>
{t(
'If you wish to be able to co-edit in real-time, contact your Information Systems Security Manager about allowing WebSockets.',
)}
</Text>
</Text> </Text>
</Box> </Box>
</Modal> </Modal>

View File

@@ -0,0 +1,32 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
type DocCanEditResponse = { can_edit: boolean };
export const docCanEdit = async (id: string): Promise<DocCanEditResponse> => {
const response = await fetchAPI(`documents/${id}/can-edit/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<DocCanEditResponse>;
};
export const KEY_CAN_EDIT = 'doc-can-edit';
export function useDocCanEdit(
param: string,
queryConfig?: UseQueryOptions<
DocCanEditResponse,
APIError,
DocCanEditResponse
>,
) {
return useQuery<DocCanEditResponse, APIError, DocCanEditResponse>({
queryKey: [KEY_CAN_EDIT, param],
queryFn: () => docCanEdit(param),
...queryConfig,
});
}

View File

@@ -1,10 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api'; import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs'; import { Doc } from '@/features/docs';
import { KEY_CAN_EDIT } from './useDocCanEdit';
export type UpdateDocParams = Pick<Doc, 'id'> & export type UpdateDocParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'content' | 'title'>>; Partial<Pick<Doc, 'content' | 'title'>> & {
websocket?: boolean;
};
export const updateDoc = async ({ export const updateDoc = async ({
id, id,
@@ -24,25 +32,30 @@ export const updateDoc = async ({
return response.json() as Promise<Doc>; return response.json() as Promise<Doc>;
}; };
interface UpdateDocProps { type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
onSuccess?: (data: Doc) => void;
listInvalideQueries?: string[]; listInvalideQueries?: string[];
} };
export function useUpdateDoc({ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
onSuccess,
listInvalideQueries,
}: UpdateDocProps = {}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({ return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc, mutationFn: updateDoc,
onSuccess: (data) => { ...queryConfig,
listInvalideQueries?.forEach((queryKey) => { onSuccess: (data, variables, context) => {
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [queryKey], queryKey: [queryKey],
}); });
}); });
onSuccess?.(data);
if (queryConfig?.onSuccess) {
void queryConfig.onSuccess(data, variables, context);
}
},
onError: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_CAN_EDIT],
});
}, },
}); });
} }

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useConfig } from '@/core'; import { useConfig } from '@/core';
import { useIsOffline } from '@/features/service-worker'; import { useIsOffline } from '@/features/service-worker';
import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit';
import { useProviderStore } from '../stores'; import { useProviderStore } from '../stores';
import { Doc, LinkReach } from '../types'; import { Doc, LinkReach } from '../types';
@@ -13,31 +14,30 @@ export const useIsCollaborativeEditable = (doc: Doc) => {
const docIsPublic = doc.link_reach === LinkReach.PUBLIC; const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const docHasMember = doc.nb_accesses_direct > 1; const docHasMember = doc.nb_accesses_direct > 1;
const isUserReader = !doc.abilities.partial_update;
const isShared = docIsPublic || docIsAuth || docHasMember; const isShared = docIsPublic || docIsAuth || docHasMember;
const [isEditable, setIsEditable] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const { isOffline } = useIsOffline(); const { isOffline } = useIsOffline();
const _isEditable = isUserReader || isConnected || !isShared || isOffline;
const [isEditable, setIsEditable] = useState(true);
const [isLoading, setIsLoading] = useState(!_isEditable);
const {
data: { can_edit } = { can_edit: _isEditable },
isLoading: isLoadingCanEdit,
} = useDocCanEdit(doc.id, {
enabled: !_isEditable,
queryKey: [KEY_CAN_EDIT, doc.id],
staleTime: 0,
});
/**
* Connection can take a few seconds
*/
useEffect(() => { useEffect(() => {
const _isEditable = isConnected || !isShared || isOffline; if (isLoadingCanEdit) {
setIsLoading(true);
if (_isEditable) {
setIsEditable(true);
setIsLoading(false);
return; return;
} }
const timer = setTimeout(() => { setIsEditable(can_edit);
setIsEditable(false); setIsLoading(false);
setIsLoading(false); }, [can_edit, isLoadingCanEdit]);
}, 5000);
return () => clearTimeout(timer);
}, [isConnected, isOffline, isShared]);
if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) { if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) {
return { return {