mirror of
https://github.com/docmost/docmost.git
synced 2026-08-19 18:11:35 +10:00
feat(ee): space control to hide comments from viewers role
This commit is contained in:
@@ -508,6 +508,11 @@
|
||||
"Allow viewers to comment": "Allow viewers to comment",
|
||||
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
|
||||
"Toggle viewer comments": "Toggle viewer comments",
|
||||
"Hide comments from viewers": "Hide comments from viewers",
|
||||
"Viewers cannot see or add comments on pages in this space.": "Viewers cannot see or add comments on pages in this space.",
|
||||
"Toggle hide comments from viewers": "Toggle hide comments from viewers",
|
||||
"Turn off 'Allow viewers to comment' first": "Turn off 'Allow viewers to comment' first",
|
||||
"Turn off 'Hide comments from viewers' first": "Turn off 'Hide comments from viewers' first",
|
||||
"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.",
|
||||
"Page permissions": "Page permissions",
|
||||
|
||||
@@ -11,11 +11,13 @@ import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const canViewComments = useCanViewComments();
|
||||
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,12 +25,18 @@ export default function Aside() {
|
||||
document.getElementById(ASIDE_PANEL_ID)?.focus();
|
||||
}, [isAsideOpen, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAsideOpen && tab === "comments" && !canViewComments) {
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}
|
||||
}, [isAsideOpen, tab, canViewComments, setAsideState]);
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs />;
|
||||
component = canViewComments ? <CommentListWithTabs /> : null;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
|
||||
@@ -19,6 +19,7 @@ export const Feature = {
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
TEMPLATES: 'templates',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
HIDE_COMMENTS: 'comment:hide',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
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";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
type SpaceHideCommentsToggleProps = {
|
||||
space: ISpace;
|
||||
};
|
||||
|
||||
export default function SpaceHideCommentsToggle({
|
||||
space,
|
||||
}: SpaceHideCommentsToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasHideComments = useHasFeature(Feature.HIDE_COMMENTS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const allowViewerCommentsEnabled =
|
||||
space.settings?.comments?.allowViewerComments === true;
|
||||
const isDisabled = !hasHideComments || allowViewerCommentsEnabled;
|
||||
const tooltipLabel = !hasHideComments
|
||||
? upgradeLabel
|
||||
: t("Turn off 'Allow viewers to comment' first");
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.comments?.hideCommentsFromViewers === true,
|
||||
);
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
hideCommentsFromViewers: value,
|
||||
});
|
||||
setChecked(value);
|
||||
} catch {
|
||||
// error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Hide comments from viewers")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Viewers cannot see or add comments on pages in this space.")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip label={tooltipLabel} disabled={!isDisabled} refProp="rootRef">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
aria-label={t("Toggle hide comments from viewers")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,12 @@ export default function SpaceViewerCommentsToggle({
|
||||
const { t } = useTranslation();
|
||||
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDisabled = !hasViewerComments;
|
||||
const hideCommentsEnabled =
|
||||
space.settings?.comments?.hideCommentsFromViewers === true;
|
||||
const isDisabled = !hasViewerComments || hideCommentsEnabled;
|
||||
const tooltipLabel = !hasViewerComments
|
||||
? upgradeLabel
|
||||
: t("Turn off 'Hide comments from viewers' first");
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.comments?.allowViewerComments === true,
|
||||
);
|
||||
@@ -45,7 +50,7 @@ export default function SpaceViewerCommentsToggle({
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
label={tooltipLabel}
|
||||
disabled={!isDisabled}
|
||||
refProp="rootRef"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
export function useCanViewComments(): boolean {
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
|
||||
return (
|
||||
canEdit || space?.settings?.comments?.hideCommentsFromViewers !== true
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,8 @@ import {
|
||||
getCollabSocket,
|
||||
releaseCollabSocket,
|
||||
} from "@/features/editor/collab-socket";
|
||||
import clsx from "clsx";
|
||||
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -196,6 +198,7 @@ function CollabPageEditor({
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
const canViewComments = useCanViewComments();
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
@@ -372,6 +375,7 @@ function CollabPageEditor({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewComments) return;
|
||||
document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent);
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
@@ -379,7 +383,7 @@ function CollabPageEditor({
|
||||
handleActiveCommentEvent,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}, [canViewComments]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCommentId(null);
|
||||
@@ -430,7 +434,13 @@ function CollabPageEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div
|
||||
className={clsx(
|
||||
"editor-container",
|
||||
!canViewComments && "comments-hidden",
|
||||
)}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
@@ -480,17 +490,21 @@ function StaticPageEditor({
|
||||
content: any;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const canViewComments = useCanViewComments();
|
||||
|
||||
return (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": ariaLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className={clsx(!canViewComments && "comments-hidden")}>
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": ariaLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,3 +315,8 @@
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-hidden .ProseMirror .comment-mark {
|
||||
background: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
<PageShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Comments")}
|
||||
{...commentsTriggerProps}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{canViewComments && (
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Comments")}
|
||||
{...commentsTriggerProps}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!page?.isBase && (
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
|
||||
@@ -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({
|
||||
<Divider my="lg" />
|
||||
|
||||
<SpaceViewerCommentsToggle space={space} />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SpaceHideCommentsToggle space={space} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, any> | null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, any> | null;
|
||||
if (!settings?.comments?.allowViewerComments) {
|
||||
if (
|
||||
!settings?.comments?.allowViewerComments ||
|
||||
settings?.comments?.hideCommentsFromViewers
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
async validateCanViewComments(
|
||||
page: Page,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
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<string, any> | 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<string[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hideCommentsFromViewers: boolean;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,35 @@ import {
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
export function validateExclusiveCommentSettings(
|
||||
dto: Partial<
|
||||
Pick<UpdateSpaceDto, 'allowViewerComments' | 'hideCommentsFromViewers'>
|
||||
>,
|
||||
settingsBefore: Record<string, any>,
|
||||
): 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<string, any>;
|
||||
|
||||
validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
|
||||
|
||||
const before: Record<string, any> = {};
|
||||
const after: Record<string, any> = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Set<string>> {
|
||||
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<string[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
|
||||
@@ -149,6 +149,17 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getSpaceSettings(
|
||||
spaceId: string,
|
||||
): Promise<Record<string, any> | null> {
|
||||
const row = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select('settings')
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
return (row?.settings as Record<string, any> | undefined) ?? null;
|
||||
}
|
||||
|
||||
async insertSpace(
|
||||
insertableSpace: InsertableSpace,
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 05529bcf97...f396df9bc5
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string[]>,
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
const settings = await this.spaceRepo.getSpaceSettings(spaceId);
|
||||
return settings?.comments?.hideCommentsFromViewers === true;
|
||||
}
|
||||
|
||||
private extractPageId(data: any): string | null {
|
||||
switch (data.operation) {
|
||||
case 'addTreeNode':
|
||||
|
||||
Reference in New Issue
Block a user