@@ -480,17 +490,21 @@ function StaticPageEditor({
content: any;
ariaLabel: string;
}) {
+ const canViewComments = useCanViewComments();
+
return (
-
+
+
+
);
}
diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css
index ef61425a9..657f12ea5 100644
--- a/apps/client/src/features/editor/styles/core.css
+++ b/apps/client/src/features/editor/styles/core.css
@@ -315,3 +315,8 @@
height: 100%;
}
}
+
+.comments-hidden .ProseMirror .comment-mark {
+ background: none;
+ border-bottom: none;
+}
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index e011e9ec4..00e3036f5 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -58,6 +58,7 @@ import {
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
+import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -65,6 +66,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const commentsTriggerProps = useAsideTriggerProps("comments");
+ const canViewComments = useCanViewComments();
const tocTriggerProps = useAsideTriggerProps("toc");
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
@@ -105,16 +107,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
-
-
-
-
-
+ {canViewComments && (
+
+
+
+
+
+ )}
{!page?.isBase && (
diff --git a/apps/client/src/features/space/components/space-security-settings.tsx b/apps/client/src/features/space/components/space-security-settings.tsx
index a18735343..e51e93fa7 100644
--- a/apps/client/src/features/space/components/space-security-settings.tsx
+++ b/apps/client/src/features/space/components/space-security-settings.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
+import SpaceHideCommentsToggle from "@/ee/security/components/space-hide-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
@@ -29,6 +30,10 @@ export default function SpaceSecuritySettings({
+
+
+
+
);
}
diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts
index 6937b233c..72faa40ec 100644
--- a/apps/client/src/features/space/types/space.types.ts
+++ b/apps/client/src/features/space/types/space.types.ts
@@ -11,6 +11,7 @@ export interface ISpaceSharingSettings {
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
+ hideCommentsFromViewers?: boolean;
}
export interface ISpaceSettings {
@@ -36,6 +37,7 @@ export interface ISpace {
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
+ hideCommentsFromViewers?: boolean;
}
interface IMembership {
diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts
index 2a889fd08..6bc9d13bd 100644
--- a/apps/server/src/common/features.ts
+++ b/apps/server/src/common/features.ts
@@ -18,6 +18,7 @@ export const Feature = {
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
+ HIDE_COMMENTS: 'comment:hide',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts
index 22458848b..1c1da9899 100644
--- a/apps/server/src/core/comment/comment.controller.ts
+++ b/apps/server/src/core/comment/comment.controller.ts
@@ -89,20 +89,29 @@ export class CommentController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(input.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
- await this.pageAccessService.validateCanView(page, user);
+ await this.pageAccessService.validateCanViewComments(
+ page,
+ user,
+ workspace.id,
+ );
return this.commentService.findByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('info')
- async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) {
+ async findOne(
+ @Body() input: CommentIdDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -113,7 +122,11 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
- await this.pageAccessService.validateCanView(page, user);
+ await this.pageAccessService.validateCanViewComments(
+ page,
+ user,
+ workspace.id,
+ );
return comment;
}
diff --git a/apps/server/src/core/notification/services/comment.notification.ts b/apps/server/src/core/notification/services/comment.notification.ts
index c79c2895f..d39edd1f5 100644
--- a/apps/server/src/core/notification/services/comment.notification.ts
+++ b/apps/server/src/core/notification/services/comment.notification.ts
@@ -14,6 +14,7 @@ import { CommentMentionEmail } from '@docmost/transactional/emails/comment-menti
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
+import { PageAccessService } from '../../page/page-access/page-access.service';
@Injectable()
export class CommentNotificationService {
@@ -25,6 +26,7 @@ export class CommentNotificationService {
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
+ private readonly pageAccessService: PageAccessService,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
@@ -48,7 +50,7 @@ export class CommentNotificationService {
);
if (!context) return;
- const { actor, pageTitle, pageUrl } = context;
+ const { actor, pageTitle, pageUrl, spaceSettings } = context;
const notifiedUserIds = new Set
();
notifiedUserIds.add(actorId);
@@ -72,7 +74,16 @@ export class CommentNotificationService {
pageId,
[...usersWithSpaceAccess],
);
- const usersWithAccess = new Set(usersWithPageAccess);
+ let accessibleUserIds = usersWithPageAccess;
+ if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
+ accessibleUserIds =
+ await this.pageAccessService.filterUserIdsWithPageEditAccess(
+ spaceId,
+ pageId,
+ accessibleUserIds,
+ );
+ }
+ const usersWithAccess = new Set(accessibleUserIds);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -145,7 +156,7 @@ export class CommentNotificationService {
);
if (!context) return;
- const { actor, pageTitle, pageUrl } = context;
+ const { actor, pageTitle, pageUrl, spaceSettings } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
@@ -166,6 +177,16 @@ export class CommentNotificationService {
);
if (hasPageAccess.length === 0) return;
+ if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
+ const editCapable =
+ await this.pageAccessService.filterUserIdsWithPageEditAccess(
+ spaceId,
+ pageId,
+ [commentCreatorId],
+ );
+ if (editCapable.length === 0) return;
+ }
+
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -225,7 +246,7 @@ export class CommentNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
- .select(['id', 'slug'])
+ .select(['id', 'slug', 'settings'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -236,6 +257,11 @@ export class CommentNotificationService {
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
- return { actor, pageTitle: getPageTitle(page.title), pageUrl };
+ return {
+ actor,
+ pageTitle: getPageTitle(page.title),
+ pageUrl,
+ spaceSettings: (space.settings ?? null) as Record | null,
+ };
}
}
diff --git a/apps/server/src/core/page/page-access/page-access.service.ts b/apps/server/src/core/page/page-access/page-access.service.ts
index 6d6db03fa..6b42ed486 100644
--- a/apps/server/src/core/page/page-access/page-access.service.ts
+++ b/apps/server/src/core/page/page-access/page-access.service.ts
@@ -7,6 +7,7 @@ import {
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
+import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class PageAccessService {
@@ -14,6 +15,7 @@ export class PageAccessService {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
+ private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
/**
@@ -118,8 +120,68 @@ export class PageAccessService {
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record | null;
- if (!settings?.comments?.allowViewerComments) {
+ if (
+ !settings?.comments?.allowViewerComments ||
+ settings?.comments?.hideCommentsFromViewers
+ ) {
throw new ForbiddenException();
}
}
+
+ async validateCanViewComments(
+ page: Page,
+ user: User,
+ workspaceId: string,
+ ): Promise {
+ const { canEdit } = await this.validateCanViewWithPermissions(page, user);
+ if (canEdit) {
+ return;
+ }
+
+ const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
+ const settings = space?.settings as Record | null;
+ if (settings?.comments?.hideCommentsFromViewers) {
+ throw new ForbiddenException();
+ }
+ }
+
+ /**
+ * Callers must pass userIds that already have space access (WS room members / pre-filtered notification recipients).
+ */
+ async filterUserIdsWithPageEditAccess(
+ spaceId: string,
+ pageId: string,
+ userIds: string[],
+ ): Promise {
+ if (userIds.length === 0) {
+ return [];
+ }
+
+ const spaceHasRestrictedPages =
+ await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
+ const hasRestriction =
+ spaceHasRestrictedPages &&
+ (await this.pagePermissionRepo.hasRestrictedAncestor(pageId));
+
+ if (!hasRestriction) {
+ const editCapableIds =
+ await this.spaceMemberRepo.getUserIdsWithSpaceEditAccess(
+ userIds,
+ spaceId,
+ );
+ return userIds.filter((id) => editCapableIds.has(id));
+ }
+
+ const results = await Promise.all(
+ userIds.map(async (userId) => {
+ const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
+ userId,
+ pageId,
+ );
+ return canEdit ? userId : null;
+ }),
+ );
+
+ return results.filter((id): id is string => id !== null);
+ }
}
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 8b40e8944..8da122b59 100644
--- a/apps/server/src/core/space/dto/update-space.dto.ts
+++ b/apps/server/src/core/space/dto/update-space.dto.ts
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ hideCommentsFromViewers: boolean;
}
diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts
index af1a61079..d270f4fef 100644
--- a/apps/server/src/core/space/services/space.service.ts
+++ b/apps/server/src/core/space/services/space.service.ts
@@ -30,6 +30,35 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
+export function validateExclusiveCommentSettings(
+ dto: Partial<
+ Pick
+ >,
+ settingsBefore: Record,
+): void {
+ if (
+ dto.allowViewerComments === undefined &&
+ dto.hideCommentsFromViewers === undefined
+ ) {
+ return;
+ }
+
+ const allowViewerComments =
+ dto.allowViewerComments ??
+ settingsBefore.comments?.allowViewerComments ??
+ false;
+ const hideCommentsFromViewers =
+ dto.hideCommentsFromViewers ??
+ settingsBefore.comments?.hideCommentsFromViewers ??
+ false;
+
+ if (allowViewerComments && hideCommentsFromViewers) {
+ throw new BadRequestException(
+ "'Allow viewers to comment' and 'Hide comments from viewers' cannot both be enabled",
+ );
+ }
+}
+
@Injectable()
export class SpaceService {
constructor(
@@ -141,7 +170,8 @@ export class SpaceService {
if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
- typeof updateSpaceDto.allowViewerComments !== 'undefined'
+ typeof updateSpaceDto.allowViewerComments !== 'undefined' ||
+ typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
@@ -168,6 +198,17 @@ export class SpaceService {
) {
throw new ForbiddenException('This feature requires a valid license');
}
+
+ if (
+ updateSpaceDto.hideCommentsFromViewers === true &&
+ !this.licenseCheckService.hasFeature(
+ workspace.licenseKey,
+ Feature.HIDE_COMMENTS,
+ workspace.plan,
+ )
+ ) {
+ throw new ForbiddenException('This feature requires a valid license');
+ }
}
const spaceBefore = await this.spaceRepo.findById(
@@ -176,6 +217,8 @@ export class SpaceService {
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record;
+ validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
+
const before: Record = {};
const after: Record = {};
@@ -218,6 +261,23 @@ export class SpaceService {
);
}
+ if (typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined') {
+ const prev = settingsBefore?.comments?.hideCommentsFromViewers ?? false;
+ if (prev !== updateSpaceDto.hideCommentsFromViewers) {
+ before.hideCommentsFromViewers = prev;
+ after.hideCommentsFromViewers =
+ updateSpaceDto.hideCommentsFromViewers;
+ }
+
+ await this.spaceRepo.updateCommentSettings(
+ updateSpaceDto.spaceId,
+ workspaceId,
+ 'hideCommentsFromViewers',
+ updateSpaceDto.hideCommentsFromViewers,
+ trx,
+ );
+ }
+
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts
index 6711c30c7..fc3df1fec 100644
--- a/apps/server/src/database/repos/space/space-member.repo.ts
+++ b/apps/server/src/database/repos/space/space-member.repo.ts
@@ -20,6 +20,7 @@ import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
+import { SpaceRole } from '../../../common/helpers/types/permission';
@Injectable()
export class SpaceMemberRepo {
@@ -278,6 +279,32 @@ export class SpaceMemberRepo {
return new Set(rows.map((r) => r.userId));
}
+ async getUserIdsWithSpaceEditAccess(
+ userIds: string[],
+ spaceId: string,
+ ): Promise> {
+ if (userIds.length === 0) return new Set();
+
+ const rows = await this.db
+ .selectFrom('spaceMembers')
+ .select('userId')
+ .where('userId', 'in', userIds)
+ .where('spaceId', '=', spaceId)
+ .where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER])
+ .unionAll(
+ this.db
+ .selectFrom('spaceMembers')
+ .innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
+ .select('groupUsers.userId')
+ .where('groupUsers.userId', 'in', userIds)
+ .where('spaceMembers.spaceId', '=', spaceId)
+ .where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER]),
+ )
+ .execute();
+
+ return new Set(rows.map((r) => r.userId));
+ }
+
async getSpaceIdsByGroupId(groupId: string): Promise {
const rows = await this.db
.selectFrom('spaceMembers')
diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts
index 905d2fac6..4196c13df 100644
--- a/apps/server/src/database/repos/space/space.repo.ts
+++ b/apps/server/src/database/repos/space/space.repo.ts
@@ -149,6 +149,17 @@ export class SpaceRepo {
.executeTakeFirst();
}
+ async getSpaceSettings(
+ spaceId: string,
+ ): Promise | null> {
+ const row = await this.db
+ .selectFrom('spaces')
+ .select('settings')
+ .where('id', '=', spaceId)
+ .executeTakeFirst();
+ return (row?.settings as Record | undefined) ?? null;
+ }
+
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 05529bcf9..f396df9bc 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 05529bcf97919d84f17442a9faf1a93904a5a85a
+Subproject commit f396df9bc5aeed681a82a7265ecd7696fdd9d336
diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts
index 3278f72cb..2cbf80d1a 100644
--- a/apps/server/src/ws/ws.service.ts
+++ b/apps/server/src/ws/ws.service.ts
@@ -3,6 +3,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Server, Socket } from 'socket.io';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
+import { PageAccessService } from '../core/page/page-access/page-access.service';
import {
TREE_EVENTS,
WS_SPACE_RESTRICTION_CACHE_PREFIX,
@@ -17,6 +19,8 @@ export class WsService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
+ private readonly spaceRepo: SpaceRepo,
+ private readonly pageAccessService: PageAccessService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
@@ -67,9 +71,24 @@ export class WsService {
spaceId: string,
pageId: string,
data: any,
+ opts?: { bypassVisibilityCheck?: boolean },
): Promise {
const room = getSpaceRoomName(spaceId);
+ if (
+ !opts?.bypassVisibilityCheck &&
+ (await this.spaceHidesCommentsFromViewers(spaceId))
+ ) {
+ await this.broadcastToUsersMatching(room, null, data, (candidateIds) =>
+ this.pageAccessService.filterUserIdsWithPageEditAccess(
+ spaceId,
+ pageId,
+ candidateIds,
+ ),
+ );
+ return;
+ }
+
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
@@ -118,6 +137,17 @@ export class WsService {
excludeSocketId: string | null,
pageId: string,
data: any,
+ ): Promise {
+ await this.broadcastToUsersMatching(room, excludeSocketId, data, (ids) =>
+ this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, ids),
+ );
+ }
+
+ private async broadcastToUsersMatching(
+ room: string,
+ excludeSocketId: string | null,
+ data: any,
+ filterUserIds: (candidateUserIds: string[]) => Promise,
): Promise {
const sockets = await this.server.in(room).fetchSockets();
@@ -144,15 +174,9 @@ export class WsService {
const candidateUserIds = Array.from(userSocketMap.keys());
if (candidateUserIds.length === 0) return;
- const authorizedUserIds =
- await this.pagePermissionRepo.getUserIdsWithPageAccess(
- pageId,
- candidateUserIds,
- );
-
- const authorizedSet = new Set(authorizedUserIds);
+ const allowedSet = new Set(await filterUserIds(candidateUserIds));
for (const [userId, userSockets] of userSocketMap) {
- if (authorizedSet.has(userId)) {
+ if (allowedSet.has(userId)) {
for (const socket of userSockets) {
socket.emit('message', data);
}
@@ -176,6 +200,13 @@ export class WsService {
return hasRestrictions;
}
+ private async spaceHidesCommentsFromViewers(
+ spaceId: string,
+ ): Promise {
+ const settings = await this.spaceRepo.getSpaceSettings(spaceId);
+ return settings?.comments?.hideCommentsFromViewers === true;
+ }
+
private extractPageId(data: any): string | null {
switch (data.operation) {
case 'addTreeNode':