From c2f41118b6de3258471af1b8bf8a989b4aadfd03 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 26 Mar 2023 14:51:18 +0200 Subject: [PATCH] feat: check permissions in realtime code and frontend Signed-off-by: Philip Molares Signed-off-by: Tilman Vatteroth --- .../realtime-note/realtime-connection.spec.ts | 10 ++- .../realtime-note/realtime-connection.ts | 5 +- .../websocket/websocket.gateway.spec.ts | 16 +++- .../realtime/websocket/websocket.gateway.ts | 14 ++- commons/src/index.ts | 2 + commons/src/utils/permissions.spec.ts | 87 +++++++++++++++++++ commons/src/utils/permissions.ts | 51 +++++++++++ commons/src/utils/permissions.types.ts | 31 +++++++ .../src/y-doc-sync/y-doc-sync-adapter.spec.ts | 6 +- commons/src/y-doc-sync/y-doc-sync-adapter.ts | 10 ++- .../y-doc-sync/y-doc-sync-server-adapter.ts | 10 ++- frontend/src/api/notes/types.ts | 19 +--- frontend/src/api/permissions/index.ts | 4 +- .../aliases/aliases-add-form.spec.tsx | 5 +- .../permissions/permission-entry-buttons.tsx | 2 +- .../permission-entry-special-group.tsx | 2 +- .../permissions/permission-entry-user.tsx | 4 +- .../permission-section-special-groups.tsx | 2 +- .../document-bar/permissions/types.ts | 15 ---- .../editor-page/editor-pane/editor-pane.tsx | 6 +- frontend/src/hooks/common/use-is-owner.ts | 6 +- frontend/src/hooks/common/use-may-edit.ts | 21 +++++ frontend/src/redux/note-details/methods.ts | 5 +- ...uild-state-from-server-permissions.spec.ts | 2 +- .../build-state-from-server-permissions.ts | 4 +- frontend/src/redux/note-details/types.ts | 5 +- frontend/src/test-utils/note-ownership.ts | 9 +- 27 files changed, 287 insertions(+), 66 deletions(-) create mode 100644 commons/src/utils/permissions.spec.ts create mode 100644 commons/src/utils/permissions.ts create mode 100644 commons/src/utils/permissions.types.ts delete mode 100644 frontend/src/components/editor-page/document-bar/permissions/types.ts create mode 100644 frontend/src/hooks/common/use-may-edit.ts diff --git a/backend/src/realtime/realtime-note/realtime-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts index d2754f5bf..7c17567e6 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -53,6 +53,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); expect(sut.getTransporter()).toBe(mockedMessageTransporter); }); @@ -62,6 +63,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote); }); @@ -76,6 +78,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus); @@ -91,6 +94,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter); @@ -101,6 +105,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient'); @@ -115,6 +120,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); expect(sut.getUser()).toBe(mockedUser); @@ -127,6 +133,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUserWithUsername, mockedRealtimeNote, + true, ); expect(sut.getDisplayName()).toBe('MockUser'); @@ -143,6 +150,7 @@ describe('websocket connection', () => { mockedMessageTransporter, mockedUser, mockedRealtimeNote, + true, ); expect(sut.getDisplayName()).toBe(randomName); diff --git a/backend/src/realtime/realtime-note/realtime-connection.ts b/backend/src/realtime/realtime-note/realtime-connection.ts index 3aae04137..6e215e37b 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -28,12 +28,14 @@ export class RealtimeConnection { * @param messageTransporter The message transporter that handles the communication with the client. * @param user The user of the client * @param realtimeNote The {@link RealtimeNote} that the client connected to. + * @param acceptEdits If edits by this connection should be accepted. * @throws Error if the socket is not open */ constructor( messageTransporter: MessageTransporter, private user: User | null, private realtimeNote: RealtimeNote, + private acceptEdits: boolean, ) { this.displayName = user?.displayName ?? generateRandomName(); this.transporter = messageTransporter; @@ -44,6 +46,7 @@ export class RealtimeConnection { this.yDocSyncAdapter = new YDocSyncServerAdapter( this.transporter, realtimeNote.getRealtimeDoc(), + acceptEdits, ); this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter( this.user?.username ?? null, diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts index 2f5ab0d8c..dafaafa48 100644 --- a/backend/src/realtime/websocket/websocket.gateway.spec.ts +++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -195,8 +195,18 @@ describe('Websocket gateway', () => { } }); - const mockedNote = Mock.of({ id: 4711 }); - const mockedGuestNote = Mock.of({ id: 1235 }); + const mockedNote = Mock.of({ + id: 4711, + owner: Promise.resolve(mockUser), + userPermissions: Promise.resolve([]), + groupPermissions: Promise.resolve([]), + }); + const mockedGuestNote = Mock.of({ + id: 1235, + owner: Promise.resolve(null), + userPermissions: Promise.resolve([]), + groupPermissions: Promise.resolve([]), + }); jest .spyOn(notesService, 'getNoteByIdOrAlias') .mockImplementation((noteId: string) => { diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index 48ad651ac..5226f5130 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -1,9 +1,13 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import { WebsocketTransporter } from '@hedgedoc/commons'; +import { + NotePermissions, + userCanEdit, + WebsocketTransporter, +} from '@hedgedoc/commons'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; @@ -77,10 +81,16 @@ export class WebsocketGateway implements OnGatewayConnection { await this.realtimeNoteService.getOrCreateRealtimeNote(note); const websocketTransporter = new WebsocketTransporter(); + const permissions = await this.noteService.toNotePermissionsDto(note); + const acceptEdits: boolean = userCanEdit( + permissions as NotePermissions, + user?.username, + ); const connection = new RealtimeConnection( websocketTransporter, user, realtimeNote, + acceptEdits, ); websocketTransporter.setWebsocket(clientSocket); diff --git a/commons/src/index.ts b/commons/src/index.ts index d416c8a20..b1aaf5873 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -15,6 +15,8 @@ export { MissingTrailingSlashError, WrongProtocolError } from './utils/errors.js' +export * from './utils/permissions.js' +export * from './utils/permissions.types.js' export * from './y-doc-sync/y-doc-sync-client-adapter.js' export * from './y-doc-sync/y-doc-sync-server-adapter.js' diff --git a/commons/src/utils/permissions.spec.ts b/commons/src/utils/permissions.spec.ts new file mode 100644 index 000000000..605e16fbe --- /dev/null +++ b/commons/src/utils/permissions.spec.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { userCanEdit, userIsOwner } from './permissions.js' +import { NotePermissions, SpecialGroup } from './permissions.types.js' +import { describe, expect, it } from '@jest/globals' + +describe('Permissions', () => { + const testPermissions: NotePermissions = { + owner: 'owner', + sharedToUsers: [ + { + username: 'logged_in', + canEdit: true + } + ], + sharedToGroups: [ + { + groupName: SpecialGroup.EVERYONE, + canEdit: true + }, + { + groupName: SpecialGroup.LOGGED_IN, + canEdit: true + } + ] + } + describe('userIsOwner', () => { + it('returns true, if user is owner', () => { + expect(userIsOwner(testPermissions, 'owner')).toBeTruthy() + }) + it('returns false, if user is not ownerr', () => { + expect(userIsOwner(testPermissions, 'not_owner')).toBeFalsy() + }) + it('returns false, if user is undefined', () => { + expect(userIsOwner(testPermissions, undefined)).toBeFalsy() + }) + }) + + describe('userCanEdit', () => { + it('returns true, if user is owner', () => { + expect(userCanEdit(testPermissions, 'owner')).toBeTruthy() + }) + it('returns true, if user is logged in and this is user specifically may edit', () => { + expect( + userCanEdit({ ...testPermissions, sharedToGroups: [] }, 'logged_in') + ).toBeTruthy() + }) + it('returns true, if user is logged in and loggedIn users may edit', () => { + expect( + userCanEdit({ ...testPermissions, sharedToUsers: [] }, 'logged_in') + ).toBeTruthy() + }) + it('returns true, if user is guest and guests are allowed to edit', () => { + expect( + userCanEdit({ ...testPermissions, sharedToUsers: [] }, undefined) + ).toBeTruthy() + }) + it('returns false, if user is logged in and loggedIn users may not edit', () => { + expect( + userCanEdit( + { ...testPermissions, sharedToUsers: [], sharedToGroups: [] }, + 'logged_in' + ) + ).toBeFalsy() + }) + it('returns false, if user is guest and guests are not allowed to edit', () => { + expect( + userCanEdit( + { + ...testPermissions, + sharedToUsers: [], + sharedToGroups: [ + { + groupName: SpecialGroup.LOGGED_IN, + canEdit: true + } + ] + }, + undefined + ) + ).toBeFalsy() + }) + }) +}) diff --git a/commons/src/utils/permissions.ts b/commons/src/utils/permissions.ts new file mode 100644 index 000000000..3cf03b1de --- /dev/null +++ b/commons/src/utils/permissions.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { NotePermissions, SpecialGroup } from './permissions.types.js' + +/** + * Checks if the given user is the owner of a note. + * + * @param permissions The permissions of the note to check + * @param user The username of the user + * @return True if the user is the owner of the note + */ +export const userIsOwner = ( + permissions: NotePermissions, + user?: string +): boolean => { + return !!user && permissions.owner === user +} + +/** + * Checks if the given user may edit a note. + * + * @param permissions The permissions of the note to check + * @param user The username of the user + * @return True if the user has the permission to edit the note + */ +export const userCanEdit = ( + permissions: NotePermissions, + user?: string +): boolean => { + const isOwner = userIsOwner(permissions, user) + const mayWriteViaUserPermission = permissions.sharedToUsers.some( + (value) => value.canEdit && value.username === user + ) + const mayWriteViaGroupPermission = + !!user && + permissions.sharedToGroups.some( + (value) => value.groupName === SpecialGroup.LOGGED_IN && value.canEdit + ) + const everyoneMayWriteViaGroupPermission = permissions.sharedToGroups.some( + (value) => value.groupName === SpecialGroup.EVERYONE && value.canEdit + ) + return ( + isOwner || + mayWriteViaUserPermission || + mayWriteViaGroupPermission || + everyoneMayWriteViaGroupPermission + ) +} diff --git a/commons/src/utils/permissions.types.ts b/commons/src/utils/permissions.types.ts new file mode 100644 index 000000000..bb8f95e7e --- /dev/null +++ b/commons/src/utils/permissions.types.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface NotePermissions { + owner: string | null + sharedToUsers: NoteUserPermissionEntry[] + sharedToGroups: NoteGroupPermissionEntry[] +} + +export interface NoteUserPermissionEntry { + username: string + canEdit: boolean +} + +export interface NoteGroupPermissionEntry { + groupName: string + canEdit: boolean +} +export enum AccessLevel { + NONE, + READ_ONLY, + WRITEABLE +} + +export enum SpecialGroup { + EVERYONE = '_EVERYONE', + LOGGED_IN = '_LOGGED_IN' +} diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts index dbb09aa54..683fb3929 100644 --- a/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts +++ b/commons/src/y-doc-sync/y-doc-sync-adapter.spec.ts @@ -104,12 +104,14 @@ describe('message transporter', () => { const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter( transporterServerTo1, - docServer + docServer, + true ) const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter( transporterServerTo2, - docServer + docServer, + true ) const waitForClient1Sync = new Promise((resolve) => { diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.ts index 3d2778a2e..75d12af72 100644 --- a/commons/src/y-doc-sync/y-doc-sync-adapter.ts +++ b/commons/src/y-doc-sync/y-doc-sync-adapter.ts @@ -28,7 +28,7 @@ export abstract class YDocSyncAdapter { this.yDocUpdateListener = doc.on( 'update', (update, origin) => { - this.processDocUpdate(update, origin) + this.distributeDocUpdate(update, origin) }, { objectify: true @@ -92,7 +92,7 @@ export abstract class YDocSyncAdapter { const noteContentUpdateListener = this.messageTransporter.on( MessageType.NOTE_CONTENT_UPDATE, - (payload) => this.doc.applyUpdate(payload.payload, this), + (payload) => this.applyIncomingUpdatePayload(payload.payload), { objectify: true } ) as Listener @@ -103,7 +103,11 @@ export abstract class YDocSyncAdapter { } } - private processDocUpdate(update: number[], origin: unknown): void { + protected applyIncomingUpdatePayload(update: number[]): void { + this.doc.applyUpdate(update, this) + } + + private distributeDocUpdate(update: number[], origin: unknown): void { if (!this.isSynced() || origin === this) { return } diff --git a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts index a1c9c1d1c..24f72dfb9 100644 --- a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts +++ b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts @@ -10,9 +10,17 @@ import { YDocSyncAdapter } from './y-doc-sync-adapter.js' export class YDocSyncServerAdapter extends YDocSyncAdapter { constructor( readonly messageTransporter: MessageTransporter, - readonly doc: RealtimeDoc + readonly doc: RealtimeDoc, + readonly acceptEdits: boolean ) { super(messageTransporter, doc) this.markAsSynced() } + + protected applyIncomingUpdatePayload(update: number[]): void { + if (!this.acceptEdits) { + return + } + super.applyIncomingUpdatePayload(update) + } } diff --git a/frontend/src/api/notes/types.ts b/frontend/src/api/notes/types.ts index fd6287505..a41d667a8 100644 --- a/frontend/src/api/notes/types.ts +++ b/frontend/src/api/notes/types.ts @@ -1,9 +1,10 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import type { Alias } from '../alias/types' +import type { NotePermissions } from '@hedgedoc/commons' export interface Note { content: string @@ -35,22 +36,6 @@ export interface NoteEdit { updatedAt: string } -export interface NotePermissions { - owner: string | null - sharedToUsers: NoteUserPermissionEntry[] - sharedToGroups: NoteGroupPermissionEntry[] -} - -export interface NoteUserPermissionEntry { - username: string - canEdit: boolean -} - -export interface NoteGroupPermissionEntry { - groupName: string - canEdit: boolean -} - export interface NoteDeletionOptions { keepMedia: boolean } diff --git a/frontend/src/api/permissions/index.ts b/frontend/src/api/permissions/index.ts index d6a6c2647..dcb1a3c14 100644 --- a/frontend/src/api/permissions/index.ts +++ b/frontend/src/api/permissions/index.ts @@ -1,12 +1,12 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder' import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder' -import type { NotePermissions } from '../notes/types' import type { OwnerChangeDto, PermissionSetDto } from './types' +import type { NotePermissions } from '@hedgedoc/commons' /** * Sets the owner of a note. diff --git a/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx b/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx index b99cca806..ce6b26481 100644 --- a/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx +++ b/frontend/src/components/editor-page/document-bar/aliases/aliases-add-form.spec.tsx @@ -4,8 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import * as AliasModule from '../../../../api/alias' -import * as useApplicationStateModule from '../../../../hooks/common/use-application-state' import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods' +import type { NoteDetails } from '../../../../redux/note-details/types/note-details' +import { mockNoteOwnership } from '../../../../test-utils/note-ownership' import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n' import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary' import { AliasesAddForm } from './aliases-add-form' @@ -25,12 +26,12 @@ describe('AliasesAddForm', () => { await mockI18n() jest.spyOn(AliasModule, 'addAlias').mockImplementation(() => addPromise) jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve()) - jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue('mock-note') jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({ showErrorNotification: jest.fn(), dismissNotification: jest.fn(), dispatchUiNotification: jest.fn() }) + mockNoteOwnership('test', 'test', { noteDetails: { id: 'mock-note' } as NoteDetails }) }) afterAll(() => { diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx index a45df0a95..2b5094c9b 100644 --- a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx +++ b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-buttons.tsx @@ -5,7 +5,7 @@ */ import { UiIcon } from '../../../common/icons/ui-icon' import type { PermissionDisabledProps } from './permission-disabled.prop' -import { AccessLevel } from './types' +import { AccessLevel } from '@hedgedoc/commons' import React, { useMemo } from 'react' import { Button, ToggleButtonGroup } from 'react-bootstrap' import { Eye as IconEye } from 'react-bootstrap-icons' diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx index e4d63f96a..68ac37a48 100644 --- a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx +++ b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx @@ -9,7 +9,7 @@ import { setNotePermissionsFromServer } from '../../../../redux/note-details/met import { IconButton } from '../../../common/icon-button/icon-button' import { useUiNotifications } from '../../../notifications/ui-notification-boundary' import type { PermissionDisabledProps } from './permission-disabled.prop' -import { AccessLevel, SpecialGroup } from './types' +import { AccessLevel, SpecialGroup } from '@hedgedoc/commons' import React, { useCallback, useMemo } from 'react' import { ToggleButtonGroup } from 'react-bootstrap' import { Eye as IconEye } from 'react-bootstrap-icons' diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx index db2fd17a5..c4aa58061 100644 --- a/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx +++ b/frontend/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx @@ -3,7 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NoteUserPermissionEntry } from '../../../../api/notes/types' import { removeUserPermission, setUserPermission } from '../../../../api/permissions' import { getUser } from '../../../../api/users' import { useApplicationState } from '../../../../hooks/common/use-application-state' @@ -13,7 +12,8 @@ import { UserAvatarForUser } from '../../../common/user-avatar/user-avatar-for-u import { useUiNotifications } from '../../../notifications/ui-notification-boundary' import type { PermissionDisabledProps } from './permission-disabled.prop' import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons' -import { AccessLevel } from './types' +import type { NoteUserPermissionEntry } from '@hedgedoc/commons' +import { AccessLevel } from '@hedgedoc/commons' import React, { useCallback } from 'react' import { useAsync } from 'react-use' diff --git a/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx b/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx index 985fee502..898692bde 100644 --- a/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx +++ b/frontend/src/components/editor-page/document-bar/permissions/permission-section-special-groups.tsx @@ -7,7 +7,7 @@ import { useApplicationState } from '../../../../hooks/common/use-application-st import { useIsOwner } from '../../../../hooks/common/use-is-owner' import type { PermissionDisabledProps } from './permission-disabled.prop' import { PermissionEntrySpecialGroup } from './permission-entry-special-group' -import { AccessLevel, SpecialGroup } from './types' +import { AccessLevel, SpecialGroup } from '@hedgedoc/commons' import React, { Fragment, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' diff --git a/frontend/src/components/editor-page/document-bar/permissions/types.ts b/frontend/src/components/editor-page/document-bar/permissions/types.ts deleted file mode 100644 index 35491b167..000000000 --- a/frontend/src/components/editor-page/document-bar/permissions/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -export enum AccessLevel { - NONE, - READ_ONLY, - WRITEABLE -} - -export enum SpecialGroup { - EVERYONE = '_EVERYONE', - LOGGED_IN = '_LOGGED_IN' -} diff --git a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx index 70ce7062f..5c69320a4 100644 --- a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx @@ -6,6 +6,7 @@ import { useApplicationState } from '../../../hooks/common/use-application-state' import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url' import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state' +import { useMayEdit } from '../../../hooks/common/use-may-edit' import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name' import type { ScrollProps } from '../synced-scroll/scroll-props' @@ -130,6 +131,7 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, o const darkModeActivated = useDarkModeState() const editorOrigin = useBaseUrl(ORIGIN.EDITOR) const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced) + const mayEdit = useMayEdit() useEffect(() => { const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady()) @@ -144,11 +146,11 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, o onTouchStart={onMakeScrollSource} onMouseEnter={onMakeScrollSource} {...cypressId('editor-pane')} - {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}> + {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced && mayEdit))}> { - const owner = useApplicationState((state) => state.noteDetails.permissions.owner) const me: string | undefined = useApplicationState((state) => state.user?.username) + const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions) - return useMemo(() => !!me && owner === me, [owner, me]) + return useMemo(() => userIsOwner(permissions, me), [permissions, me]) } diff --git a/frontend/src/hooks/common/use-may-edit.ts b/frontend/src/hooks/common/use-may-edit.ts new file mode 100644 index 000000000..756d2fe47 --- /dev/null +++ b/frontend/src/hooks/common/use-may-edit.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useApplicationState } from './use-application-state' +import type { NotePermissions } from '@hedgedoc/commons' +import { userCanEdit } from '@hedgedoc/commons' +import { useMemo } from 'react' + +/** + * Determines if the current user is allowed to write to this note. + * + * @return True, if the current user is allowed to write. + */ +export const useMayEdit = (): boolean => { + const me: string | undefined = useApplicationState((state) => state.user?.username) + const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions) + + return useMemo(() => userCanEdit(permissions, me), [permissions, me]) +} diff --git a/frontend/src/redux/note-details/methods.ts b/frontend/src/redux/note-details/methods.ts index c975c4536..cff1e6357 100644 --- a/frontend/src/redux/note-details/methods.ts +++ b/frontend/src/redux/note-details/methods.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { store } from '..' import { getNoteMetadata } from '../../api/notes' -import type { Note, NotePermissions } from '../../api/notes/types' +import type { Note } from '../../api/notes/types' import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection' import type { SetNoteDetailsFromServerAction, @@ -16,6 +16,7 @@ import type { UpdateNoteTitleByFirstHeadingAction } from './types' import { NoteDetailsActionType } from './types' +import type { NotePermissions } from '@hedgedoc/commons' /** * Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part. diff --git a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts index e2f6415bc..88c4ec7e8 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.spec.ts @@ -3,10 +3,10 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NotePermissions } from '../../../api/notes/types' import { initialState } from '../initial-state' import type { NoteDetails } from '../types/note-details' import { buildStateFromServerPermissions } from './build-state-from-server-permissions' +import type { NotePermissions } from '@hedgedoc/commons' describe('build state from server permissions', () => { it('creates a new state with the given permissions', () => { diff --git a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts index 77a5eceae..7281f0f2f 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-server-permissions.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NotePermissions } from '../../../api/notes/types' import type { NoteDetails } from '../types/note-details' +import type { NotePermissions } from '@hedgedoc/commons' /** * Builds the updated state from a given previous state and updated NotePermissions data. diff --git a/frontend/src/redux/note-details/types.ts b/frontend/src/redux/note-details/types.ts index 95d2d2dfa..96b16c204 100644 --- a/frontend/src/redux/note-details/types.ts +++ b/frontend/src/redux/note-details/types.ts @@ -1,10 +1,11 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Note, NoteMetadata, NotePermissions } from '../../api/notes/types' +import type { Note, NoteMetadata } from '../../api/notes/types' import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection' +import type { NotePermissions } from '@hedgedoc/commons' import type { Action } from 'redux' export enum NoteDetailsActionType { diff --git a/frontend/src/test-utils/note-ownership.ts b/frontend/src/test-utils/note-ownership.ts index cfe3487f7..23276dad3 100644 --- a/frontend/src/test-utils/note-ownership.ts +++ b/frontend/src/test-utils/note-ownership.ts @@ -7,15 +7,22 @@ import * as useApplicationStateModule from '../hooks/common/use-application-stat import type { ApplicationState } from '../redux/application-state' jest.mock('../hooks/common/use-application-state') -export const mockNoteOwnership = (ownUsername: string, noteOwner: string) => { +export const mockNoteOwnership = ( + ownUsername: string, + noteOwner: string, + additionalState?: Partial +) => { jest.spyOn(useApplicationStateModule, 'useApplicationState').mockImplementation((fn) => { return fn({ + ...additionalState, noteDetails: { + ...additionalState?.noteDetails, permissions: { owner: noteOwner } }, user: { + ...additionalState?.user, username: ownUsername } } as ApplicationState)