From 1ad53c25811727e70eb60712812f6c4617e50472 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:35:36 -0800 Subject: [PATCH] feat(ee): public sharing controls (#1910) * feat(ee): public sharing controls * lint --- .../public/locales/en-US/translation.json | 15 ++++ .../src/ee/hooks/use-enterprise-access.tsx | 12 +++ .../components/disable-public-sharing.tsx | 88 +++++++++++++++++++ .../ee/security/components/enforce-mfa.tsx | 27 +++--- .../space-public-sharing-toggle.tsx | 84 ++++++++++++++++++ .../client/src/ee/security/pages/security.tsx | 36 +++++--- .../features/share/components/share-modal.tsx | 23 +++++ .../space/components/space-details.tsx | 12 ++- .../src/features/space/types/space.types.ts | 11 +++ .../workspace/services/workspace-service.ts | 7 +- .../workspace/types/workspace.types.ts | 7 ++ apps/server/package.json | 2 +- .../server/src/core/share/share.controller.ts | 43 ++++++++- apps/server/src/core/share/share.service.ts | 25 ++++++ .../src/core/space/dto/update-space.dto.ts | 6 +- .../src/core/space/services/space.service.ts | 32 +++++++ .../workspace/dto/update-workspace.dto.ts | 4 + .../workspace/services/workspace.service.ts | 30 +++++++ .../20260205T214213-add-settings-to-spaces.ts | 9 ++ .../src/database/repos/share/share.repo.ts | 14 +++ .../src/database/repos/space/space.repo.ts | 20 +++++ .../repos/workspace/workspace.repo.ts | 22 ++++- apps/server/src/database/types/db.d.ts | 1 + .../environment/environment.module.ts | 5 +- .../environment/license-check.service.ts | 28 ++++++ 25 files changed, 525 insertions(+), 38 deletions(-) create mode 100644 apps/client/src/ee/hooks/use-enterprise-access.tsx create mode 100644 apps/client/src/ee/security/components/disable-public-sharing.tsx create mode 100644 apps/client/src/ee/security/components/space-public-sharing-toggle.tsx create mode 100644 apps/server/src/database/migrations/20260205T214213-add-settings-to-spaces.ts create mode 100644 apps/server/src/integrations/environment/license-check.service.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 4f775e6c..fa040002 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -407,6 +407,21 @@ "Share deleted successfully": "Share deleted successfully", "Share not found": "Share not found", "Failed to share page": "Failed to share page", + "Disable public sharing": "Disable public sharing", + "Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.", + "Toggle public sharing": "Toggle public sharing", + "Toggle space public sharing": "Toggle space public sharing", + "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", + "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", + "Requires an enterprise license": "Requires an enterprise license", + "Enable public sharing": "Enable public sharing", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.", + "Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.", + "Public sharing is disabled": "Public sharing is disabled", + "Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.", + "Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.", "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully", diff --git a/apps/client/src/ee/hooks/use-enterprise-access.tsx b/apps/client/src/ee/hooks/use-enterprise-access.tsx new file mode 100644 index 00000000..b7746d6f --- /dev/null +++ b/apps/client/src/ee/hooks/use-enterprise-access.tsx @@ -0,0 +1,12 @@ +import { isCloud } from "@/lib/config"; +import useLicense from "@/ee/hooks/use-license"; +import usePlan from "@/ee/hooks/use-plan"; + +const useEnterpriseAccess = () => { + const { hasLicenseKey } = useLicense(); + const { isBusiness } = usePlan(); + + return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey); +}; + +export default useEnterpriseAccess; diff --git a/apps/client/src/ee/security/components/disable-public-sharing.tsx b/apps/client/src/ee/security/components/disable-public-sharing.tsx new file mode 100644 index 00000000..a5d9f34c --- /dev/null +++ b/apps/client/src/ee/security/components/disable-public-sharing.tsx @@ -0,0 +1,88 @@ +import { Group, Text, Switch, Tooltip } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; + +export default function DisablePublicSharing() { + const { t } = useTranslation(); + + return ( + +
+ {t("Disable public sharing")} + + {t("Prevent members from sharing pages publicly.")} + +
+ + +
+ ); +} + +function DisablePublicSharingToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.sharing?.disabled === true, + ); + const hasAccess = useEnterpriseAccess(); + + const applyChange = async (value: boolean) => { + try { + const updatedWorkspace = await updateWorkspace({ + disablePublicSharing: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + + modals.openConfirmModal({ + title: value ? t("Disable public sharing") : t("Enable public sharing"), + children: ( + + {value + ? t( + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.", + ) + : t( + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.", + )} + + ), + centered: true, + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + confirmProps: value ? { color: "red" } : {}, + onConfirm: () => applyChange(value), + }); + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/security/components/enforce-mfa.tsx b/apps/client/src/ee/security/components/enforce-mfa.tsx index 37cf5152..b716e200 100644 --- a/apps/client/src/ee/security/components/enforce-mfa.tsx +++ b/apps/client/src/ee/security/components/enforce-mfa.tsx @@ -10,23 +10,18 @@ export default function EnforceMfa() { const { t } = useTranslation(); return ( - <> - - MFA - - -
- {t("Enforce two-factor authentication")} - - {t( - "Once enforced, all members must enable two-factor authentication to access the workspace.", - )} - -
+ +
+ {t("Enforce two-factor authentication")} + + {t( + "Once enforced, all members must enable two-factor authentication to access the workspace.", + )} + +
- -
- + +
); } diff --git a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx new file mode 100644 index 00000000..f03d18f2 --- /dev/null +++ b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx @@ -0,0 +1,84 @@ +import { Group, Text, Switch, Tooltip } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts"; + +type SpacePublicSharingToggleProps = { + space: ISpace; +}; + +export default function SpacePublicSharingToggle({ + space, +}: SpacePublicSharingToggleProps) { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + const workspaceDisabled = workspace?.settings?.sharing?.disabled === true; + const [checked, setChecked] = useState( + space.settings?.sharing?.disabled === true, + ); + const updateSpaceMutation = useUpdateSpaceMutation(); + + const applyChange = async (value: boolean) => { + try { + await updateSpaceMutation.mutateAsync({ + spaceId: space.id, + disablePublicSharing: value, + }); + setChecked(value); + } catch { + // error handled by mutation + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + + modals.openConfirmModal({ + title: value ? t("Disable public sharing") : t("Enable public sharing"), + children: ( + + {value + ? t( + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.", + ) + : t( + "Are you sure you want to enable public sharing for this space?", + )} + + ), + centered: true, + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + confirmProps: value ? { color: "red" } : {}, + onConfirm: () => applyChange(value), + }); + }; + + return ( + +
+ {t("Disable public sharing")} + + {workspaceDisabled + ? t("Public sharing is disabled at the workspace level") + : t("Prevent pages in this space from being shared publicly.")} + +
+ + + +
+ ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index 82d8640f..a32c5867 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -9,15 +9,16 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx" import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import { useTranslation } from "react-i18next"; -import useLicense from "@/ee/hooks/use-license.tsx"; -import usePlan from "@/ee/hooks/use-plan.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; +import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; +import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; export default function Security() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const { hasLicenseKey } = useLicense(); - const { isBusiness } = usePlan(); + const hasEnterpriseAccess = useEnterpriseAccess(); + const isCloudEE = useIsCloudEE(); if (!isAdmin) { return null; @@ -30,26 +31,41 @@ export default function Security() { - - - - + {(!isCloud() || hasEnterpriseAccess) && ( + <> + + + + )} + Single sign-on (SSO) - {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? ( + {hasEnterpriseAccess && ( <> + + )} + + {isCloudEE && ( + <> + + + + )} + + {hasEnterpriseAccess && ( + <> - ) : null} + )} diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx index cf597e0a..6d5e1c8c 100644 --- a/apps/client/src/features/share/components/share-modal.tsx +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -26,6 +26,9 @@ import { getAppUrl, isCloud } from "@/lib/config.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import classes from "@/features/share/components/share.module.css"; import useTrial from "@/ee/hooks/use-trial.tsx"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; interface ShareModalProps { readOnly: boolean; @@ -40,6 +43,12 @@ export default function ShareModal({ readOnly }: ShareModalProps) { const { data: share } = useShareForPageQuery(pageId); const { spaceSlug } = useParams(); const { isTrial } = useTrial(); + const [workspace] = useAtom(workspaceAtom); + const { data: space } = useSpaceQuery(spaceSlug); + const workspaceDisabled = + workspace?.settings?.sharing?.disabled === true; + const spaceDisabled = space?.settings?.sharing?.disabled === true; + const sharingDisabled = workspaceDisabled || spaceDisabled; const createShareMutation = useCreateShareMutation(); const updateShareMutation = useUpdateShareMutation(); const deleteShareMutation = useDeleteShareMutation(); @@ -164,6 +173,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) { {t("Upgrade Plan")} + ) : sharingDisabled ? ( + <> + + + + + {t("Public sharing is disabled")} + + + {workspaceDisabled + ? t("Public sharing has been disabled at the workspace level.") + : t("Public sharing has been disabled for this space.")} + + ) : isDescendantShared ? ( <> {t("Inherits public sharing from")} diff --git a/apps/client/src/features/space/components/space-details.tsx b/apps/client/src/features/space/components/space-details.tsx index 3c3eb178..89109b3f 100644 --- a/apps/client/src/features/space/components/space-details.tsx +++ b/apps/client/src/features/space/components/space-details.tsx @@ -18,6 +18,8 @@ import { ResponsiveSettingsControl, ResponsiveSettingsRow, } from "@/components/ui/responsive-settings-row.tsx"; +import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx"; +import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; interface SpaceDetailsProps { spaceId: string; @@ -26,6 +28,8 @@ interface SpaceDetailsProps { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { const { t } = useTranslation(); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); + const hasEnterpriseAccess = useEnterpriseAccess(); + const showSharingToggle = !readOnly && hasEnterpriseAccess; const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [isIconUploading, setIsIconUploading] = useState(false); @@ -77,7 +81,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { fallbackName={space.name} size={"60px"} variant="filled" - type={AvatarIconType.SPACE_ICON} onUpload={handleIconUpload} onRemove={handleIconRemove} @@ -88,6 +91,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { + {showSharingToggle && ( + <> + + + + )} + {!readOnly && ( <> diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index de8f83de..f7dcc11a 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -5,6 +5,14 @@ import { } from "@/features/space/permissions/permissions.type.ts"; import { ExportFormat } from "@/features/page/types/page.types.ts"; +export interface ISpaceSharingSettings { + disabled?: boolean; +} + +export interface ISpaceSettings { + sharing?: ISpaceSharingSettings; +} + export interface ISpace { id: string; name: string; @@ -18,6 +26,9 @@ export interface ISpace { memberCount?: number; spaceId?: string; membership?: IMembership; + settings?: ISpaceSettings; + // for updates + disablePublicSharing?: boolean; } interface IMembership { diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index e47e2972..2c36968f 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: { await api.post("/workspace/members/delete", data); } -export async function updateWorkspace(data: Partial & { aiSearch?: boolean }) { +export async function updateWorkspace(data: Partial) { const req = await api.post("/workspace/update", data); return req.data; } @@ -66,7 +66,9 @@ export async function createInvitation(data: ICreateInvite) { return req.data; } -export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> { +export async function acceptInvitation( + data: IAcceptInvite, +): Promise<{ requiresLogin?: boolean }> { const req = await api.post("/workspace/invites/accept", data); return req.data; } @@ -108,4 +110,3 @@ export async function getAppVersion(): Promise { const req = await api.post("/version"); return req.data; } - diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index f7d0b964..cb40bc16 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -22,16 +22,23 @@ export interface IWorkspace { plan?: string; hasLicenseKey?: boolean; enforceMfa?: boolean; + aiSearch?: boolean; + disablePublicSharing?: boolean; } export interface IWorkspaceSettings { ai?: IWorkspaceAiSettings; + sharing?: IWorkspaceSharingSettings; } export interface IWorkspaceAiSettings { search?: boolean; } +export interface IWorkspaceSharingSettings { + disabled?: boolean; +} + export interface ICreateInvite { role: string; emails: string[]; diff --git a/apps/server/package.json b/apps/server/package.json index d2fcc05d..2a4bdb61 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -92,9 +92,9 @@ "pdfjs-dist": "^5.4.394", "pg-tsquery": "^8.4.2", "pgvector": "^0.2.1", - "postgres": "^3.4.8", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", + "postgres": "^3.4.8", "postmark": "^4.0.5", "react": "^18.3.1", "reflect-metadata": "^0.2.2", diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index ef6e9b2a..fb7639c1 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -64,8 +64,18 @@ export class ShareController { throw new BadRequestException(); } + const shareData = await this.shareService.getSharedPage(dto, workspace.id); + + const sharingAllowed = await this.shareService.isSharingAllowed( + workspace.id, + shareData.share.spaceId, + ); + if (!sharingAllowed) { + throw new NotFoundException('Shared page not found'); + } + return { - ...(await this.shareService.getSharedPage(dto, workspace.id)), + ...shareData, hasLicenseKey: hasLicenseOrEE({ licenseKey: workspace.licenseKey, isCloud: this.environmentService.isCloud(), @@ -86,6 +96,14 @@ export class ShareController { throw new NotFoundException('Share not found'); } + const sharingAllowed = await this.shareService.isSharingAllowed( + share.workspaceId, + share.spaceId, + ); + if (!sharingAllowed) { + throw new NotFoundException('Share not found'); + } + return share; } @@ -127,6 +145,14 @@ export class ShareController { throw new ForbiddenException(); } + const sharingAllowed = await this.shareService.isSharingAllowed( + workspace.id, + page.spaceId, + ); + if (!sharingAllowed) { + throw new ForbiddenException('Public sharing is disabled'); + } + return this.shareService.createShare({ page, authUserId: user.id, @@ -176,8 +202,21 @@ export class ShareController { @Body() dto: ShareIdDto, @AuthWorkspace() workspace: Workspace, ) { + const treeData = await this.shareService.getShareTree( + dto.shareId, + workspace.id, + ); + + const sharingAllowed = await this.shareService.isSharingAllowed( + workspace.id, + treeData.share.spaceId, + ); + if (!sharingAllowed) { + throw new NotFoundException('Share not found'); + } + return { - ...(await this.shareService.getShareTree(dto.shareId, workspace.id)), + ...treeData, hasLicenseKey: hasLicenseOrEE({ licenseKey: workspace.licenseKey, isCloud: this.environmentService.isCloud(), diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 82b8660c..c34ebff9 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -264,6 +264,31 @@ export class ShareService { return ancestor; } + async isSharingAllowed( + workspaceId: string, + spaceId: string, + ): Promise { + const result = await this.db + .selectFrom('workspaces') + .innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id') + .select([ + 'workspaces.settings as workspaceSettings', + 'spaces.settings as spaceSettings', + ]) + .where('workspaces.id', '=', workspaceId) + .where('spaces.id', '=', spaceId) + .executeTakeFirst(); + + if (!result) return false; + + const workspaceDisabled = + (result.workspaceSettings as any)?.sharing?.disabled === true; + const spaceDisabled = + (result.spaceSettings as any)?.sharing?.disabled === true; + + return !workspaceDisabled && !spaceDisabled; + } + async updatePublicAttachments(page: Page): Promise { const prosemirrorJson = getProsemirrorContent(page.content); const attachmentIds = getAttachmentIds(prosemirrorJson); diff --git a/apps/server/src/core/space/dto/update-space.dto.ts b/apps/server/src/core/space/dto/update-space.dto.ts index 9de945cc..47f1529b 100644 --- a/apps/server/src/core/space/dto/update-space.dto.ts +++ b/apps/server/src/core/space/dto/update-space.dto.ts @@ -1,10 +1,14 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateSpaceDto } from './create-space.dto'; -import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { @IsString() @IsNotEmpty() @IsUUID() spaceId: string; + + @IsOptional() + @IsBoolean() + disablePublicSharing: boolean; } diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index cc811f97..7e8e99d9 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -17,12 +18,18 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants'; import { Queue } from 'bullmq'; import { InjectQueue } from '@nestjs/bullmq'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; @Injectable() export class SpaceService { constructor( private spaceRepo: SpaceRepo, private spaceMemberService: SpaceMemberService, + private shareRepo: ShareRepo, + private workspaceRepo: WorkspaceRepo, + private licenseCheckService: LicenseCheckService, @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, ) {} @@ -105,6 +112,31 @@ export class SpaceService { } } + if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') { + const workspace = await this.workspaceRepo.findById(workspaceId, { + withLicenseKey: true, + }); + + if ( + !this.licenseCheckService.isValidEELicense(workspace.licenseKey) + ) { + throw new ForbiddenException( + 'This feature requires a valid enterprise license', + ); + } + + await this.spaceRepo.updateSharingSettings( + updateSpaceDto.spaceId, + workspaceId, + 'disabled', + updateSpaceDto.disablePublicSharing, + ); + + if (updateSpaceDto.disablePublicSharing) { + await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId); + } + } + return await this.spaceRepo.updateSpace( { name: updateSpaceDto.name, diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 2b61c7ee..7b4f31eb 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -30,4 +30,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsBoolean() generativeAi: boolean; + + @IsOptional() + @IsBoolean() + disablePublicSharing: boolean; } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 7be5b642..07b5581f 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -5,6 +5,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { SpaceService } from '../../space/services/space.service'; @@ -33,6 +34,7 @@ import { Queue } from 'bullmq'; import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; @Injectable() export class WorkspaceService { @@ -47,6 +49,8 @@ export class WorkspaceService { private userRepo: UserRepo, private environmentService: EnvironmentService, private domainService: DomainService, + private licenseCheckService: LicenseCheckService, + private shareRepo: ShareRepo, @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @@ -358,6 +362,32 @@ export class WorkspaceService { delete updateWorkspaceDto.generativeAi; } + if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') { + const currentWorkspace = await this.workspaceRepo.findById(workspaceId, { + withLicenseKey: true, + }); + + if ( + !this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey) + ) { + throw new ForbiddenException( + 'This feature requires a valid enterprise license', + ); + } + + await this.workspaceRepo.updateSharingSettings( + workspaceId, + 'disabled', + updateWorkspaceDto.disablePublicSharing, + ); + + if (updateWorkspaceDto.disablePublicSharing) { + await this.shareRepo.deleteByWorkspaceId(workspaceId); + } + + delete updateWorkspaceDto.disablePublicSharing; + } + await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); const workspace = await this.workspaceRepo.findById(workspaceId, { diff --git a/apps/server/src/database/migrations/20260205T214213-add-settings-to-spaces.ts b/apps/server/src/database/migrations/20260205T214213-add-settings-to-spaces.ts new file mode 100644 index 00000000..5c365b0c --- /dev/null +++ b/apps/server/src/database/migrations/20260205T214213-add-settings-to-spaces.ts @@ -0,0 +1,9 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('spaces').dropColumn('settings').execute(); +} diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts index 994f054f..631e0697 100644 --- a/apps/server/src/database/repos/share/share.repo.ts +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -136,6 +136,20 @@ export class ShareRepo { await query.execute(); } + async deleteBySpaceId(spaceId: string): Promise { + await this.db + .deleteFrom('shares') + .where('spaceId', '=', spaceId) + .execute(); + } + + async deleteByWorkspaceId(workspaceId: string): Promise { + await this.db + .deleteFrom('shares') + .where('workspaceId', '=', workspaceId) + .execute(); + } + async getShares(userId: string, pagination: PaginationOptions) { const query = this.db .selectFrom('shares') diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index e5bb5472..0e2bd2b7 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -89,6 +89,26 @@ export class SpaceRepo { .executeTakeFirst(); } + async updateSharingSettings( + spaceId: string, + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + ) { + return this.db + .updateTable('spaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', spaceId) + .where('workspaceId', '=', workspaceId) + .returningAll() + .executeTakeFirst(); + } + async insertSpace( insertableSpace: InsertableSpace, trx?: KyselyTransaction, diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index d17db49b..5e054650 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -167,7 +167,7 @@ export class WorkspaceRepo { .updateTable('workspaces') .set({ settings: sql`COALESCE(settings, '{}'::jsonb) - || jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb) + || jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb) || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, updatedAt: new Date(), }) @@ -185,7 +185,25 @@ export class WorkspaceRepo { .updateTable('workspaces') .set({ settings: sql`COALESCE(settings, '{}'::jsonb) - || jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb) + || jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', workspaceId) + .returning(this.baseFields) + .executeTakeFirst(); + } + + async updateSharingSettings( + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + ) { + return this.db + .updateTable('workspaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb) || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, updatedAt: new Date(), }) diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index fe5b8fab..a4197f4e 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -273,6 +273,7 @@ export interface Spaces { id: Generated; logo: string | null; name: string | null; + settings: Json | null; slug: string; updatedAt: Generated; visibility: Generated; diff --git a/apps/server/src/integrations/environment/environment.module.ts b/apps/server/src/integrations/environment/environment.module.ts index ac41aaf1..1cf99ecc 100644 --- a/apps/server/src/integrations/environment/environment.module.ts +++ b/apps/server/src/integrations/environment/environment.module.ts @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'; import { validate } from './environment.validation'; import { envPath } from '../../common/helpers'; import { DomainService } from './domain.service'; +import { LicenseCheckService } from './license-check.service'; @Global() @Module({ @@ -15,7 +16,7 @@ import { DomainService } from './domain.service'; validate, }), ], - providers: [EnvironmentService, DomainService], - exports: [EnvironmentService, DomainService], + providers: [EnvironmentService, DomainService, LicenseCheckService], + exports: [EnvironmentService, DomainService, LicenseCheckService], }) export class EnvironmentModule {} diff --git a/apps/server/src/integrations/environment/license-check.service.ts b/apps/server/src/integrations/environment/license-check.service.ts new file mode 100644 index 00000000..a6051c79 --- /dev/null +++ b/apps/server/src/integrations/environment/license-check.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { EnvironmentService } from './environment.service'; + +@Injectable() +export class LicenseCheckService { + constructor( + private moduleRef: ModuleRef, + private environmentService: EnvironmentService, + ) {} + + isValidEELicense(licenseKey: string): boolean { + if (this.environmentService.isCloud()) { + return true; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const LicenseModule = require('../../ee/licence/license.service'); + const licenseService = this.moduleRef.get(LicenseModule.LicenseService, { + strict: false, + }); + return licenseService.isValidEELicense(licenseKey); + } catch { + return false; + } + } +}