From 2fc09ab94f301eb643cdd964f775ade39177cb58 Mon Sep 17 00:00:00 2001 From: Anton Alexeyev Date: Mon, 19 May 2025 09:53:59 +0700 Subject: [PATCH] Basic implementation of a readonly user role Signed-off-by: Anton Alexeyev --- packages/core/src/classes.ts | 2 + .../src/components/Owners.svelte | 1 + .../components/CollaborativeTextEditor.svelte | 22 +++++----- .../activity-resources/src/newActivity.ts | 2 + server-plugins/card-resources/src/index.ts | 2 + .../src/collections/postgres/migrations.ts | 14 ++++++- server/account/src/utils.ts | 41 +++++++++++++------ .../src/extensions/authentication.ts | 5 ++- server/collaborator/src/utils.ts | 6 --- server/middleware/src/spacePermissions.ts | 7 +++- server/server/src/client.ts | 2 + 11 files changed, 69 insertions(+), 35 deletions(-) diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 5d63bc58db..98bb245735 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -517,6 +517,7 @@ export interface Permission extends Doc { * @public */ export enum AccountRole { + ReadOnlyGuest = 'READONLYGUEST', DocGuest = 'DocGuest', Guest = 'GUEST', User = 'USER', @@ -528,6 +529,7 @@ export enum AccountRole { * @public */ export const roleOrder: Record = { + [AccountRole.ReadOnlyGuest]: 5, [AccountRole.DocGuest]: 10, [AccountRole.Guest]: 20, [AccountRole.User]: 30, diff --git a/plugins/setting-resources/src/components/Owners.svelte b/plugins/setting-resources/src/components/Owners.svelte index 59432a4749..c457d7596a 100644 --- a/plugins/setting-resources/src/components/Owners.svelte +++ b/plugins/setting-resources/src/components/Owners.svelte @@ -27,6 +27,7 @@ const currentAccount = getCurrentAccount() const items: DropdownIntlItem[] = [ + { id: AccountRole.ReadOnlyGuest, label: setting.string.User }, { id: AccountRole.User, label: setting.string.User }, { id: AccountRole.Maintainer, label: setting.string.Maintainer }, { id: AccountRole.Owner, label: setting.string.Owner } diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index c5f20d7137..4af3b9c51d 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -22,33 +22,33 @@ Class, type CollaborativeDoc, type Doc, - type Ref, generateId, getCurrentAccount, - makeDocCollabId + makeDocCollabId, + type Ref } from '@hcengineering/core' import { IntlString, translate } from '@hcengineering/platform' import { DrawingCmd, - KeyedAttribute, getAttribute, getClient, getFileUrl, getImageSize, - imageSizeToRatio + imageSizeToRatio, + KeyedAttribute } from '@hcengineering/presentation' import { markupToJSON } from '@hcengineering/text' import { AnySvelteComponent, Button, + getEventPositionElement, + getPopupPositionElement, IconScribble, IconSize, Loading, PopupAlignment, - ThrottledCaller, - getEventPositionElement, - getPopupPositionElement, - themeStore + themeStore, + ThrottledCaller } from '@hcengineering/ui' import view from '@hcengineering/view' import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core' @@ -83,7 +83,7 @@ import { InlineCommentCollaborationExtension } from './extension/inlineComment' import { LeftMenuExtension } from './extension/leftMenu' import { mermaidOptions } from './extension/mermaid' - import { ReferenceExtension, referenceConfig } from './extension/reference' + import { referenceConfig, ReferenceExtension } from './extension/reference' import { type FileAttachFunction } from './extension/types' import { inlineCommandsConfig } from './extensions' @@ -121,7 +121,7 @@ const dispatch = createEventDispatcher() const account = getCurrentAccount() - $: isGuest = account.role === AccountRole.DocGuest + $: isGuest = account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest const objectClass = object._class const objectId = object._id @@ -142,7 +142,7 @@ let remoteSynced = false $: loading = !localSynced && !remoteSynced - $: editable = !readonly && !contentError && remoteSynced + $: editable = !readonly && !contentError && remoteSynced && account.role !== AccountRole.ReadOnlyGuest void localProvider.loaded.then(() => (localSynced = true)) void remoteProvider.loaded.then(() => (remoteSynced = true)) diff --git a/server-plugins/activity-resources/src/newActivity.ts b/server-plugins/activity-resources/src/newActivity.ts index 93132b9614..f5a3ea01f4 100644 --- a/server-plugins/activity-resources/src/newActivity.ts +++ b/server-plugins/activity-resources/src/newActivity.ts @@ -75,6 +75,8 @@ async function createMessages ( for (const data of result) { void api.event( { + // TODO: We should decide what to do with communications package and remove this workaround + // @ts-expect-error Temporary workaround because of Communications package depends on npm core package account: systemAccount }, { diff --git a/server-plugins/card-resources/src/index.ts b/server-plugins/card-resources/src/index.ts index 5d06bff350..d2141f19ce 100644 --- a/server-plugins/card-resources/src/index.ts +++ b/server-plugins/card-resources/src/index.ts @@ -412,6 +412,8 @@ async function updateCollaborators (control: TriggerControl, ctx: TxCreateDoc | undefined): boolean { + return isGuest(account, extra) || extra?.readonly === 'true' +} + +export function isGuest (account: AccountUuid, extra: Record | undefined): boolean { + return account === GUEST_ACCOUNT && extra?.guest === 'true' +} + export function wrap ( accountMethod: (ctx: MeasureContext, db: AccountDB, branding: Branding | null, ...args: any[]) => Promise ): AccountMethodHandler { @@ -532,7 +540,7 @@ export async function selectWorkspace ( meta?: Meta ): Promise { const { workspaceUrl, kind, externalRegions = [] } = params - const { account: accountUuid, workspace: tokenWorkspaceUuid, extra } = decodeTokenVerbose(ctx, token ?? '') + let { account: accountUuid, workspace: tokenWorkspaceUuid, extra } = decodeTokenVerbose(ctx, token ?? '') const getKind = (region: string | undefined): EndpointKind => { switch (kind) { case 'external': @@ -546,7 +554,7 @@ export async function selectWorkspace ( } } - if (accountUuid === GUEST_ACCOUNT && extra?.guest === 'true') { + if (isGuest(accountUuid, extra)) { const workspace = await getWorkspaceByUrl(db, workspaceUrl) if (workspace == null) { ctx.error('Workspace not found in selectWorkspace', { workspaceUrl, kind, accountUuid, extra }) @@ -605,6 +613,13 @@ export async function selectWorkspace ( throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } + if (role === AccountRole.ReadOnlyGuest) { + if (extra == null) { + extra = {} + } + extra.readonly = 'true' + } + const wsStatus = await db.workspaceStatus.findOne({ workspaceUuid: workspace.uuid }) if (wsStatus != null) { diff --git a/server/collaborator/src/extensions/authentication.ts b/server/collaborator/src/extensions/authentication.ts index cea8d66336..47fbfd47b0 100644 --- a/server/collaborator/src/extensions/authentication.ts +++ b/server/collaborator/src/extensions/authentication.ts @@ -17,9 +17,10 @@ import { decodeDocumentId } from '@hcengineering/collaborator-client' import { MeasureContext } from '@hcengineering/core' import { decodeToken } from '@hcengineering/server-token' import { Extension, onAuthenticatePayload } from '@hocuspocus/server' +import { isReadOnlyOrGuest } from '@hcengineering/account' import { Context, buildContext } from '../context' -import { getWorkspaceIds, isGuest } from '../utils' +import { getWorkspaceIds } from '../utils' export interface AuthenticationConfiguration { ctx: MeasureContext @@ -38,7 +39,7 @@ export class AuthenticationExtension implements Extension { return await ctx.with('authenticate', { workspaceId }, async () => { const token = decodeToken(data.token) - const readonly = isGuest(token) + const readonly = isReadOnlyOrGuest(token.account, token.extra) ctx.info('authenticate', { workspaceId, diff --git a/server/collaborator/src/utils.ts b/server/collaborator/src/utils.ts index 9317357518..d3ac8d1083 100644 --- a/server/collaborator/src/utils.ts +++ b/server/collaborator/src/utils.ts @@ -12,17 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { GUEST_ACCOUNT } from '@hcengineering/account' import { getClient as getAccountClient } from '@hcengineering/account-client' import { type WorkspaceIds } from '@hcengineering/core' -import { type Token } from '@hcengineering/server-token' import config from './config' -export function isGuest (token: Token): boolean { - return token.account === GUEST_ACCOUNT && token.extra?.guest === 'true' -} - // TODO: consider storing this in a cache for some short period of time export async function getWorkspaceIds (token: string): Promise { const workspaceInfo = await getAccountClient(config.AccountsUrl, token).getWorkspaceInfo() diff --git a/server/middleware/src/spacePermissions.ts b/server/middleware/src/spacePermissions.ts index 9ac194b406..ebdf10d5f6 100644 --- a/server/middleware/src/spacePermissions.ts +++ b/server/middleware/src/spacePermissions.ts @@ -32,7 +32,7 @@ import core, { TypedSpace, type MeasureContext, type SessionData, - type AccountUuid + type AccountUuid, AccountRole } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import { Middleware, TxMiddlewareResult, type PipelineContext } from '@hcengineering/server-core' @@ -337,6 +337,11 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle } protected checkPermissions (ctx: MeasureContext, tx: Tx): void { + const account = ctx.contextData.account + if (account.role === AccountRole.ReadOnlyGuest) { + this.throwForbidden() + } + if (tx._class === core.class.TxApplyIf) { const applyTx = tx as TxApplyIf diff --git a/server/server/src/client.ts b/server/server/src/client.ts index 430df99ef6..1cfbff2779 100644 --- a/server/server/src/client.ts +++ b/server/server/src/client.ts @@ -450,6 +450,8 @@ export class ClientSession implements Session { private getCommunicationCtx (): CommunicationSession { return { sessionId: this.sessionId, + // TODO: We should decide what to do with communications package and remove this workaround + // @ts-expect-error Temporary workaround because of Communications package depends on npm core package account: this.account } }