From 59e945562d1696f0a9b0be490b041bd99dac783e Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Thu, 26 Feb 2026 19:49:10 +0000
Subject: [PATCH] feat(ee): page-level access/permissions (#1971)
* Add page_hierarchy table
* feat(ee): page-level permissions
* pagination
* rename migration
fixes
* fix
* tabs
* fix theme
* cleanup
* sync
* page permissions notification
* other fixes
* sharing disbled
* fix column nodes
* toggle error handling
---
.../public/locales/en-US/translation.json | 29 +-
.../src/ee/licence/components/oss-details.tsx | 2 +-
.../components/general-access-select.tsx | 112 ++
.../components/page-permission-item.tsx | 107 ++
.../components/page-permission-list.tsx | 164 +++
.../components/page-permission-tab.tsx | 189 +++
.../components/page-permission.module.css | 128 ++
.../components/page-share-modal.tsx | 132 ++
.../components/publish-tab.tsx | 254 ++++
.../hooks/use-page-permission.ts | 26 +
apps/client/src/ee/page-permission/index.ts | 11 +
.../queries/page-permission-query.ts | 175 +++
.../services/page-permission-service.ts | 55 +
.../types/page-permission-role-data.ts | 20 +
.../types/page-permission.types.ts | 61 +
.../components/comment-list-with-tabs.tsx | 37 +-
.../src/features/editor/title-editor.tsx | 13 +-
.../components/notification-item.tsx | 4 +
.../notification/types/notification.types.ts | 3 +-
.../components/header/page-header-menu.tsx | 4 +-
.../page/tree/components/space-tree.tsx | 122 +-
apps/client/src/features/page/tree/types.ts | 1 +
.../src/features/page/tree/utils/utils.ts | 1 +
.../src/features/page/types/page.types.ts | 5 +
.../features/share/components/share-modal.tsx | 47 +-
.../src/features/share/queries/share-query.ts | 5 +-
.../space/components/multi-member-select.tsx | 4 +-
.../space/components/settings-modal.tsx | 5 +-
.../user/components/page-state-pref.tsx | 15 +-
.../user/components/page-width-pref.tsx | 8 +-
apps/client/src/main.tsx | 1 -
apps/client/src/pages/page/page.tsx | 21 +-
apps/client/src/theme.ts | 12 +
apps/server/package.json | 9 +-
apps/server/src/app.module.ts | 15 +
.../src/collaboration/collaboration.util.ts | 4 +-
.../extensions/authentication.extension.ts | 33 +-
.../src/common/helpers/types/permission.ts | 9 +
.../core/attachment/attachment.controller.ts | 22 +-
.../src/core/comment/comment.controller.ts | 76 +-
apps/server/src/core/core.module.ts | 2 +
.../notification/notification.constants.ts | 1 +
.../core/notification/notification.module.ts | 3 +-
.../notification/notification.processor.ts | 12 +-
.../services/comment.notification.ts | 18 +-
.../services/page.notification.ts | 63 +-
.../page/page-access/page-access.module.ts | 9 +
.../page/page-access/page-access.service.ts | 102 ++
apps/server/src/core/page/page.controller.ts | 138 +-
.../src/core/page/services/page.service.ts | 294 ++++-
apps/server/src/core/search/search.service.ts | 32 +-
.../server/src/core/share/share.controller.ts | 47 +-
apps/server/src/core/share/share.service.ts | 27 +-
apps/server/src/database/database.module.ts | 3 +
.../20260224T233803-page-permissions.ts | 90 ++
.../database/pagination/cursor-pagination.ts | 15 +
.../database/repos/group/group-user.repo.ts | 10 +
.../repos/page/page-permission.repo.ts | 1109 +++++++++++++++++
.../src/database/repos/page/page.repo.ts | 76 +-
.../repos/page/types/page-permission.types.ts | 23 +
apps/server/src/database/types/db.d.ts | 25 +
.../server/src/database/types/entity.types.ts | 12 +
apps/server/src/ee | 2 +-
.../integrations/export/export.controller.ts | 11 +-
.../src/integrations/export/export.service.ts | 152 ++-
.../queue/constants/queue.constants.ts | 1 +
.../queue/constants/queue.interface.ts | 12 +-
.../emails/permission-granted-email.tsx | 45 +
apps/server/src/ws/ws-tree.service.ts | 47 +
apps/server/src/ws/ws.gateway.ts | 37 +-
apps/server/src/ws/ws.module.ts | 9 +-
apps/server/src/ws/ws.service.ts | 157 +++
apps/server/src/ws/ws.utils.ts | 17 +
apps/server/test/jest-e2e.json | 5 +
pnpm-lock.yaml | 51 +
75 files changed, 4235 insertions(+), 363 deletions(-)
create mode 100644 apps/client/src/ee/page-permission/components/general-access-select.tsx
create mode 100644 apps/client/src/ee/page-permission/components/page-permission-item.tsx
create mode 100644 apps/client/src/ee/page-permission/components/page-permission-list.tsx
create mode 100644 apps/client/src/ee/page-permission/components/page-permission-tab.tsx
create mode 100644 apps/client/src/ee/page-permission/components/page-permission.module.css
create mode 100644 apps/client/src/ee/page-permission/components/page-share-modal.tsx
create mode 100644 apps/client/src/ee/page-permission/components/publish-tab.tsx
create mode 100644 apps/client/src/ee/page-permission/hooks/use-page-permission.ts
create mode 100644 apps/client/src/ee/page-permission/index.ts
create mode 100644 apps/client/src/ee/page-permission/queries/page-permission-query.ts
create mode 100644 apps/client/src/ee/page-permission/services/page-permission-service.ts
create mode 100644 apps/client/src/ee/page-permission/types/page-permission-role-data.ts
create mode 100644 apps/client/src/ee/page-permission/types/page-permission.types.ts
create mode 100644 apps/server/src/core/page/page-access/page-access.module.ts
create mode 100644 apps/server/src/core/page/page-access/page-access.service.ts
create mode 100644 apps/server/src/database/migrations/20260224T233803-page-permissions.ts
create mode 100644 apps/server/src/database/repos/page/page-permission.repo.ts
create mode 100644 apps/server/src/database/repos/page/types/page-permission.types.ts
create mode 100644 apps/server/src/integrations/transactional/emails/permission-granted-email.tsx
create mode 100644 apps/server/src/ws/ws-tree.service.ts
create mode 100644 apps/server/src/ws/ws.service.ts
create mode 100644 apps/server/src/ws/ws.utils.ts
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 25848e5d..c8f979b7 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -429,6 +429,8 @@
"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",
+ "Page permissions": "Page permissions",
+ "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with 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.",
@@ -622,8 +624,33 @@
"commented on a page": "commented on a page",
"resolved a comment": "resolved a comment",
"mentioned you on a page": "mentioned you on a page",
+ "gave you edit access to a page": "gave you edit access to a page",
+ "gave you view access to a page": "gave you view access to a page",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
- "Older": "Older"
+ "Older": "Older",
+ "Restricted page": "Restricted page",
+ "Restricted pages cannot be shared publicly.": "Restricted pages cannot be shared publicly.",
+ "Restricted by parent": "Restricted by parent",
+ "Restricted": "Restricted",
+ "Open": "Open",
+ "Inherits restrictions from ancestor page": "Inherits restrictions from ancestor page",
+ "Only people listed below can access this page": "Only people listed below can access this page",
+ "Everyone in this space can access": "Everyone in this space can access",
+ "No additional restrictions on this page": "No additional restrictions on this page",
+ "Only specific people can access": "Only specific people can access",
+ "Use only inherited restrictions": "Use only inherited restrictions",
+ "Add restrictions on top of inherited": "Add restrictions on top of inherited",
+ "Inherited restriction": "Inherited restriction",
+ "Access limited by": "Access limited by",
+ "Restrict access to control who can view and edit this page": "Restrict access to control who can view and edit this page",
+ "Add additional restrictions specific to this page": "Add additional restrictions specific to this page",
+ "Access": "Access",
+ "People with access": "People with access",
+ "Remove all": "Remove all",
+ "Remove access": "Remove access",
+ "Remove all access": "Remove all access",
+ "Are you sure you want to remove this member's access to the page?": "Are you sure you want to remove this member's access to the page?",
+ "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space."
}
diff --git a/apps/client/src/ee/licence/components/oss-details.tsx b/apps/client/src/ee/licence/components/oss-details.tsx
index 5a3fd6c4..3f31e1a4 100644
--- a/apps/client/src/ee/licence/components/oss-details.tsx
+++ b/apps/client/src/ee/licence/components/oss-details.tsx
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder
>
- To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
+ To unlock enterprise features like SSO, AI, Page-level permissions, SSO, MFA, Resolve comments, contact sales@docmost.com.
diff --git a/apps/client/src/ee/page-permission/components/general-access-select.tsx b/apps/client/src/ee/page-permission/components/general-access-select.tsx
new file mode 100644
index 00000000..8bee6e4b
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/general-access-select.tsx
@@ -0,0 +1,112 @@
+import { Group, Menu, Text, UnstyledButton } from "@mantine/core";
+import {
+ IconChevronDown,
+ IconLock,
+ IconShieldLock,
+ IconCheck,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import classes from "./page-permission.module.css";
+
+type AccessLevel = "open" | "restricted";
+
+type GeneralAccessSelectProps = {
+ value: AccessLevel;
+ onChange: (value: AccessLevel) => void;
+ disabled?: boolean;
+ hasInheritedRestriction?: boolean;
+};
+
+export function GeneralAccessSelect({
+ value,
+ onChange,
+ disabled,
+ hasInheritedRestriction,
+}: GeneralAccessSelectProps) {
+ const { t } = useTranslation();
+
+ const isDirectlyRestricted = value === "restricted";
+ const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted;
+
+ const currentLabel = showInheritedState
+ ? t("Restricted by parent")
+ : isDirectlyRestricted
+ ? t("Restricted")
+ : t("Open");
+
+ const currentDescription = showInheritedState
+ ? t("Inherits restrictions from ancestor page")
+ : isDirectlyRestricted
+ ? t("Only people listed below can access this page")
+ : t("Everyone in this space can access");
+
+ const CurrentIcon = showInheritedState
+ ? IconShieldLock
+ : isDirectlyRestricted
+ ? IconLock
+ : IconShieldLock;
+
+ const accessOptions = [
+ {
+ value: "open" as const,
+ label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"),
+ description: hasInheritedRestriction
+ ? t("Use only inherited restrictions")
+ : t("No additional restrictions on this page"),
+ icon: IconShieldLock,
+ },
+ {
+ value: "restricted" as const,
+ label: t("Restricted"),
+ description: hasInheritedRestriction
+ ? t("Add restrictions on top of inherited")
+ : t("Only specific people can access"),
+ icon: IconLock,
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {currentLabel}
+
+ {!disabled && }
+
+
+ {currentDescription}
+
+
+
+
+
+
+ {accessOptions.map((option) => (
+ onChange(option.value)}
+ leftSection={ }
+ rightSection={
+ option.value === value ? : null
+ }
+ >
+
+ {option.label}
+
+ {option.description}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/client/src/ee/page-permission/components/page-permission-item.tsx b/apps/client/src/ee/page-permission/components/page-permission-item.tsx
new file mode 100644
index 00000000..b0a5c5f4
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/page-permission-item.tsx
@@ -0,0 +1,107 @@
+import { Menu, Text, UnstyledButton, Group } from "@mantine/core";
+import { IconChevronDown, IconCheck } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useAtomValue } from "jotai";
+import { CustomAvatar } from "@/components/ui/custom-avatar";
+import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
+import { IconGroupCircle } from "@/components/icons/icon-people-circle";
+import { userAtom } from "@/features/user/atoms/current-user-atom";
+import { formatMemberCount } from "@/lib";
+import {
+ IPagePermissionMember,
+ PagePermissionRole,
+} from "@/ee/page-permission/types/page-permission.types";
+import {
+ pagePermissionRoleData,
+ getPagePermissionRoleLabel,
+} from "@/ee/page-permission/types/page-permission-role-data";
+import classes from "./page-permission.module.css";
+
+type PagePermissionItemProps = {
+ member: IPagePermissionMember;
+ onRoleChange: (memberId: string, type: "user" | "group", role: string) => void;
+ onRemove: (memberId: string, type: "user" | "group") => void;
+ disabled?: boolean;
+};
+
+export function PagePermissionItem({
+ member,
+ onRoleChange,
+ onRemove,
+ disabled,
+}: PagePermissionItemProps) {
+ const { t } = useTranslation();
+ const currentUser = useAtomValue(userAtom);
+ const isCurrentUser = member.type === "user" && member.id === currentUser?.id;
+ const roleLabel = getPagePermissionRoleLabel(member.role);
+
+ return (
+
+
+ {member.type === "user" && (
+
+ )}
+ {member.type === "group" &&
}
+
+
+
+ {member.name}
+ {isCurrentUser && ({t("You")}) }
+
+
+ {member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
+
+
+
+
+
+ {isCurrentUser || disabled ? (
+
+ {t(roleLabel)}
+
+ ) : (
+
+
+
+
+ {t(roleLabel)}
+
+
+
+
+
+
+ {pagePermissionRoleData.map((role) => (
+ onRoleChange(member.id, member.type, role.value)}
+ rightSection={
+ role.value === member.role ? : null
+ }
+ >
+
+ {t(role.label)}
+
+ {t(role.description)}
+
+
+
+ ))}
+
+ onRemove(member.id, member.type)}
+ >
+ {t("Remove access")}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/page-permission/components/page-permission-list.tsx b/apps/client/src/ee/page-permission/components/page-permission-list.tsx
new file mode 100644
index 00000000..e586b968
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/page-permission-list.tsx
@@ -0,0 +1,164 @@
+import { Center, Group, Loader, ScrollArea, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { useAtomValue } from "jotai";
+import { useEffect, useRef } from "react";
+import { modals } from "@mantine/modals";
+import { userAtom } from "@/features/user/atoms/current-user-atom";
+import { PagePermissionRole } from "@/ee/page-permission/types/page-permission.types";
+import {
+ usePagePermissionsQuery,
+ useRemovePagePermissionMutation,
+ useUpdatePagePermissionRoleMutation,
+} from "@/ee/page-permission/queries/page-permission-query";
+import { PagePermissionItem } from "@/ee/page-permission";
+import classes from "./page-permission.module.css";
+
+type PagePermissionListProps = {
+ pageId: string;
+ canManage: boolean;
+ onRemoveAll?: () => void;
+};
+
+export function PagePermissionList({
+ pageId,
+ canManage,
+ onRemoveAll,
+}: PagePermissionListProps) {
+ const { t } = useTranslation();
+ const currentUser = useAtomValue(userAtom);
+ const updateRoleMutation = useUpdatePagePermissionRoleMutation();
+ const removeMutation = useRemovePagePermissionMutation();
+
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
+ usePagePermissionsQuery(pageId);
+
+ const sentinelRef = useRef(null);
+ const viewportRef = useRef(null);
+
+ useEffect(() => {
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ },
+ { root: viewportRef.current, threshold: 0.1 },
+ );
+
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const handleRoleChange = async (
+ memberId: string,
+ type: "user" | "group",
+ newRole: string,
+ ) => {
+ await updateRoleMutation.mutateAsync({
+ pageId,
+ role: newRole as PagePermissionRole,
+ ...(type === "user" ? { userId: memberId } : { groupId: memberId }),
+ });
+ };
+
+ const handleRemove = (memberId: string, type: "user" | "group") => {
+ modals.openConfirmModal({
+ title: t("Remove access"),
+ children: (
+
+ {t(
+ "Are you sure you want to remove this member's access to the page?",
+ )}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Remove"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: async () => {
+ await removeMutation.mutateAsync({
+ pageId,
+ ...(type === "user"
+ ? { userIds: [memberId] }
+ : { groupIds: [memberId] }),
+ });
+ },
+ });
+ };
+
+ const handleRemoveAll = () => {
+ modals.openConfirmModal({
+ title: t("Remove all access"),
+ children: (
+
+ {t(
+ "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
+ )}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Remove all"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => onRemoveAll?.(),
+ });
+ };
+
+ const members = data?.pages.flatMap((page) => page.items) ?? [];
+
+ const sortedMembers = [...members].sort((a, b) => {
+ if (a.type === "user" && a.id === currentUser?.id) return -1;
+ if (b.type === "user" && b.id === currentUser?.id) return 1;
+ if (a.type === "group" && b.type === "user") return -1;
+ if (a.type === "user" && b.type === "group") return 1;
+ return 0;
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (members.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ {t("People with access")}
+
+ {canManage && members.length > 0 && (
+
+ {t("Remove all")}
+
+ )}
+
+
+
+ {sortedMembers.map((member) => (
+
+ ))}
+
+
+
+ {isFetchingNextPage && (
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/apps/client/src/ee/page-permission/components/page-permission-tab.tsx b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx
new file mode 100644
index 00000000..93f9277c
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx
@@ -0,0 +1,189 @@
+import { useState } from "react";
+import {
+ Box,
+ Button,
+ Divider,
+ Group,
+ Paper,
+ Select,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router-dom";
+import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react";
+import { MultiMemberSelect } from "@/features/space/components/multi-member-select";
+import {
+ IPageRestrictionInfo,
+ PagePermissionRole,
+} from "@/ee/page-permission/types/page-permission.types";
+import {
+ useAddPagePermissionMutation,
+ useRestrictPageMutation,
+ useUnrestrictPageMutation,
+} from "@/ee/page-permission/queries/page-permission-query";
+import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data";
+import { GeneralAccessSelect } from "@/ee/page-permission";
+import { PagePermissionList } from "@/ee/page-permission";
+import classes from "./page-permission.module.css";
+import { buildPageUrl } from "@/features/page/page.utils";
+
+type PagePermissionTabProps = {
+ pageId: string;
+ restrictionInfo: IPageRestrictionInfo;
+};
+
+export function PagePermissionTab({
+ pageId,
+ restrictionInfo,
+}: PagePermissionTabProps) {
+ const { t } = useTranslation();
+ const { spaceSlug } = useParams();
+ const [memberIds, setMemberIds] = useState([]);
+ const [role, setRole] = useState(PagePermissionRole.WRITER);
+
+ const restrictMutation = useRestrictPageMutation();
+ const unrestrictMutation = useUnrestrictPageMutation();
+ const addPermissionMutation = useAddPagePermissionMutation();
+
+ const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction;
+ const hasDirectRestriction = restrictionInfo.hasDirectRestriction;
+ const canManage = restrictionInfo.userAccess.canManage;
+
+ const handleDirectAccessChange = async (value: "open" | "restricted") => {
+ if (value === "restricted" && !hasDirectRestriction) {
+ await restrictMutation.mutateAsync(pageId);
+ } else if (value === "open" && hasDirectRestriction) {
+ await unrestrictMutation.mutateAsync(pageId);
+ }
+ };
+
+ const handleAddMembers = async () => {
+ if (memberIds.length === 0) return;
+
+ const userIds = memberIds
+ .filter((id) => id.startsWith("user-"))
+ .map((id) => id.replace("user-", ""));
+
+ const groupIds = memberIds
+ .filter((id) => id.startsWith("group-"))
+ .map((id) => id.replace("group-", ""));
+
+ await addPermissionMutation.mutateAsync({
+ pageId,
+ role: role as PagePermissionRole,
+ ...(userIds.length > 0 && { userIds }),
+ ...(groupIds.length > 0 && { groupIds }),
+ });
+
+ setMemberIds([]);
+ };
+
+ const handleRemoveAll = async () => {
+ await unrestrictMutation.mutateAsync(pageId);
+ };
+
+ return (
+
+ {hasInheritedRestriction && (
+
+
+
+
+
+
+
+ {t("Inherited restriction")}
+
+
+
+ {t("Access limited by")}
+
+ {restrictionInfo.inheritedFrom && (
+
+
+
+ {restrictionInfo.inheritedFrom.title || t("Untitled")}
+
+
+
+
+ )}
+
+
+
+
+ )}
+
+
+
+ {!hasDirectRestriction && !hasInheritedRestriction && (
+
+ {t("Restrict access to control who can view and edit this page")}
+
+ )}
+ {!hasDirectRestriction && hasInheritedRestriction && (
+
+ {t("Add additional restrictions specific to this page")}
+
+ )}
+
+
+ {hasDirectRestriction && (
+ <>
+
+
+ {canManage && (
+
+
+
+
+ ({
+ label: t(r.label),
+ value: r.value,
+ }))}
+ value={role}
+ onChange={(value) => value && setRole(value)}
+ allowDeselect={false}
+ variant="filled"
+ w={120}
+ />
+
+ {t("Add")}
+
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/client/src/ee/page-permission/components/page-permission.module.css b/apps/client/src/ee/page-permission/components/page-permission.module.css
new file mode 100644
index 00000000..5c8b81b3
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/page-permission.module.css
@@ -0,0 +1,128 @@
+.generalAccessBox {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-sm);
+ padding: var(--mantine-spacing-xs) 0;
+}
+
+.generalAccessIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--mantine-radius-sm);
+
+ @mixin light {
+ background-color: var(--mantine-color-gray-1);
+ }
+ @mixin dark {
+ background-color: var(--mantine-color-dark-5);
+ }
+}
+
+.generalAccessIconRestricted {
+ @mixin light {
+ background-color: var(--mantine-color-red-0);
+ color: var(--mantine-color-red-6);
+ }
+ @mixin dark {
+ background-color: rgba(250, 82, 82, 0.1);
+ color: var(--mantine-color-red-5);
+ }
+}
+
+.permissionItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--mantine-spacing-xs) 0;
+ gap: var(--mantine-spacing-sm);
+}
+
+.permissionItemInfo {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-sm);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.permissionItemDetails {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.permissionItemRole {
+ flex-shrink: 0;
+}
+
+.avatarStack {
+ display: flex;
+ align-items: center;
+}
+
+.avatarStackItem {
+ margin-left: -8px;
+ border: 2px solid var(--mantine-color-body);
+ border-radius: 50%;
+}
+
+.avatarStackItem:first-child {
+ margin-left: 0;
+}
+
+.specificAccessHeader {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-xs);
+ margin-top: var(--mantine-spacing-md);
+ margin-bottom: var(--mantine-spacing-xs);
+}
+
+.removeAllLink {
+ cursor: pointer;
+ font-size: var(--mantine-font-size-sm);
+
+ @mixin light {
+ color: var(--mantine-color-gray-6);
+ }
+ @mixin dark {
+ color: var(--mantine-color-dark-2);
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.inheritedInfo {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-xs);
+ padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
+ border-radius: var(--mantine-radius-sm);
+ margin-bottom: var(--mantine-spacing-sm);
+
+ @mixin light {
+ background-color: var(--mantine-color-gray-0);
+ }
+ @mixin dark {
+ background-color: var(--mantine-color-dark-6);
+ }
+}
+
+.inheritedSection {
+ @mixin light {
+ background-color: var(--mantine-color-orange-0);
+ border: 1px solid var(--mantine-color-orange-2);
+ }
+ @mixin dark {
+ background-color: rgba(255, 146, 43, 0.08);
+ border: 1px solid rgba(255, 146, 43, 0.2);
+ }
+}
diff --git a/apps/client/src/ee/page-permission/components/page-share-modal.tsx b/apps/client/src/ee/page-permission/components/page-share-modal.tsx
new file mode 100644
index 00000000..47520fa7
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/page-share-modal.tsx
@@ -0,0 +1,132 @@
+import { useState } from "react";
+import {
+ Button,
+ Indicator,
+ Loader,
+ Modal,
+ Stack,
+ Tabs,
+ Text,
+ Center,
+} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import { IconWorld, IconLock } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+import { extractPageSlugId } from "@/lib";
+import { usePageQuery } from "@/features/page/queries/page-query";
+import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
+import { PagePermissionTab } from "@/ee/page-permission";
+import { PublishTab } from "./publish-tab";
+import { useShareForPageQuery } from "@/features/share/queries/share-query";
+import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
+import { useAtom } from "jotai";
+import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
+import { useSpaceQuery } from "@/features/space/queries/space-query";
+
+type PageShareModalProps = {
+ readOnly?: boolean;
+};
+
+export function PageShareModal({ readOnly }: PageShareModalProps) {
+ const { t } = useTranslation();
+ const { pageSlug, spaceSlug } = useParams();
+ const pageSlugId = extractPageSlugId(pageSlug);
+ const [opened, { open, close }] = useDisclosure(false);
+ const isCloudEE = useIsCloudEE();
+ const [activeTab, setActiveTab] = useState(
+ isCloudEE ? "access" : "publish",
+ );
+
+ const [workspace] = useAtom(workspaceAtom);
+ const { data: space } = useSpaceQuery(spaceSlug);
+ const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
+ const spaceSharingDisabled = space?.settings?.sharing?.disabled === true;
+
+ const { data: page } = usePageQuery({ pageId: pageSlugId });
+ const pageId = page?.id;
+ const isRestricted = page?.permissions?.hasRestriction ?? false;
+
+ const { data: share } = useShareForPageQuery(pageId);
+ const isPubliclyShared = !!share;
+
+ const { data: restrictionInfo, isLoading: restrictionLoading } =
+ usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
+
+ return (
+ <>
+
+
+
+ ) : isPubliclyShared ? (
+
+
+
+ ) : null
+ }
+ variant="default"
+ onClick={open}
+ >
+ {t("Share")}
+
+
+
+
+
+ {t("Access")}
+
+ ) : null
+ }
+ >
+ {t("Publish")}
+
+
+
+
+ {!isCloudEE ? (
+
+
+
+ {t("Page permissions")}
+
+
+ {t(
+ "Control who can view and edit individual pages. Available with an enterprise license.",
+ )}
+
+
+ ) : restrictionLoading || !pageId || !restrictionInfo ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/client/src/ee/page-permission/components/publish-tab.tsx b/apps/client/src/ee/page-permission/components/publish-tab.tsx
new file mode 100644
index 00000000..5c14fa59
--- /dev/null
+++ b/apps/client/src/ee/page-permission/components/publish-tab.tsx
@@ -0,0 +1,254 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ ActionIcon,
+ Anchor,
+ Button,
+ Group,
+ Stack,
+ Switch,
+ Text,
+ TextInput,
+} from "@mantine/core";
+import { IconExternalLink, IconLock } from "@tabler/icons-react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { getPageIcon } from "@/lib";
+import CopyTextButton from "@/components/common/copy";
+import { getAppUrl, isCloud } from "@/lib/config";
+import { buildPageUrl } from "@/features/page/page.utils";
+import {
+ useCreateShareMutation,
+ useDeleteShareMutation,
+ useShareForPageQuery,
+ useUpdateShareMutation,
+} from "@/features/share/queries/share-query";
+import useTrial from "@/ee/hooks/use-trial";
+
+type PublishTabProps = {
+ pageId: string;
+ readOnly?: boolean;
+ isRestricted?: boolean;
+ workspaceSharingDisabled?: boolean;
+ spaceSharingDisabled?: boolean;
+};
+
+export function PublishTab({ pageId, readOnly, isRestricted, workspaceSharingDisabled, spaceSharingDisabled }: PublishTabProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { pageSlug, spaceSlug } = useParams();
+ const { isTrial } = useTrial();
+
+ const { data: share } = useShareForPageQuery(pageId);
+ const createShareMutation = useCreateShareMutation();
+ const updateShareMutation = useUpdateShareMutation();
+ const deleteShareMutation = useDeleteShareMutation();
+
+ const pageIsShared = share && share.level === 0;
+ const isDescendantShared = share && share.level > 0;
+
+ const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
+
+ const [isPagePublic, setIsPagePublic] = useState(false);
+
+ useEffect(() => {
+ setIsPagePublic(!!share);
+ }, [share, pageId]);
+
+ const handleChange = async (event: React.ChangeEvent) => {
+ const value = event.currentTarget.checked;
+
+ if (value) {
+ createShareMutation.mutateAsync({
+ pageId: pageId,
+ includeSubPages: true,
+ searchIndexing: false,
+ });
+ setIsPagePublic(value);
+ } else {
+ if (share && share.id) {
+ deleteShareMutation.mutateAsync(share.id);
+ setIsPagePublic(value);
+ }
+ }
+ };
+
+ const handleSubPagesChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const value = event.currentTarget.checked;
+ updateShareMutation.mutateAsync({
+ shareId: share.id,
+ includeSubPages: value,
+ });
+ };
+
+ const handleIndexSearchChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const value = event.currentTarget.checked;
+ updateShareMutation.mutateAsync({
+ shareId: share.id,
+ searchIndexing: value,
+ });
+ };
+
+ const shareLink = useMemo(
+ () => (
+
+ }
+ style={{ width: "100%" }}
+ />
+
+
+
+
+ ),
+ [publicLink],
+ );
+
+ if (isCloud() && isTrial) {
+ return (
+
+
+
+ {t("Upgrade to share pages")}
+
+
+ {t(
+ "Page sharing is available on paid plans. Upgrade to share your pages publicly.",
+ )}
+
+ navigate("/settings/billing")}>
+ {t("Upgrade Plan")}
+
+
+ );
+ }
+
+ if (workspaceSharingDisabled || spaceSharingDisabled) {
+ return (
+
+
+
+ {t("Public sharing is disabled")}
+
+
+ {workspaceSharingDisabled
+ ? t("Public sharing has been disabled at the workspace level.")
+ : t("Public sharing has been disabled for this space.")}
+
+
+ );
+ }
+
+ if (isRestricted) {
+ return (
+
+
+
+ {t("Restricted page")}
+
+
+ {t("Restricted pages cannot be shared publicly.")}
+
+
+ );
+ }
+
+ if (isDescendantShared) {
+ return (
+
+ {t("Inherits public sharing from")}
+
+
+ {getPageIcon(share.sharedPage.icon)}
+
+ {share.sharedPage.title || t("untitled")}
+
+
+
+ {shareLink}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {isPagePublic ? t("Shared to web") : t("Share to web")}
+
+
+ {isPagePublic
+ ? t("Anyone with the link can view this page")
+ : t("Make this page publicly accessible")}
+
+
+
+
+
+ {pageIsShared && (
+ <>
+ {shareLink}
+
+
+ {t("Include sub-pages")}
+
+ {t("Make sub-pages public too")}
+
+
+
+
+
+
+ {t("Search engine indexing")}
+
+ {t("Allow search engines to index page")}
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/client/src/ee/page-permission/hooks/use-page-permission.ts b/apps/client/src/ee/page-permission/hooks/use-page-permission.ts
new file mode 100644
index 00000000..deaa8aea
--- /dev/null
+++ b/apps/client/src/ee/page-permission/hooks/use-page-permission.ts
@@ -0,0 +1,26 @@
+import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from "@/features/space/permissions/permissions.type";
+import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
+
+export function usePagePermission(pageId: string, spaceRules: any) {
+ const spaceAbility = useSpaceAbility(spaceRules);
+ const { data: restrictionInfo, isLoading } =
+ usePageRestrictionInfoQuery(pageId);
+
+ if (isLoading || !restrictionInfo) {
+ return { canEdit: false, restrictionInfo: undefined };
+ }
+
+ const hasRestriction =
+ restrictionInfo.hasDirectRestriction ||
+ restrictionInfo.hasInheritedRestriction;
+
+ const canEdit = hasRestriction
+ ? (restrictionInfo.userAccess?.canEdit ?? false)
+ : spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
+
+ return { canEdit, restrictionInfo };
+}
diff --git a/apps/client/src/ee/page-permission/index.ts b/apps/client/src/ee/page-permission/index.ts
new file mode 100644
index 00000000..4555ce54
--- /dev/null
+++ b/apps/client/src/ee/page-permission/index.ts
@@ -0,0 +1,11 @@
+export * from "./components/page-share-modal";
+export * from "./components/page-permission-tab";
+export * from "./components/publish-tab";
+export * from "./components/page-permission-list";
+export * from "./components/page-permission-item";
+export * from "./components/general-access-select";
+export * from "./hooks/use-page-permission";
+export * from "./queries/page-permission-query";
+export * from "./services/page-permission-service";
+export * from "./types/page-permission.types";
+export * from "./types/page-permission-role-data";
diff --git a/apps/client/src/ee/page-permission/queries/page-permission-query.ts b/apps/client/src/ee/page-permission/queries/page-permission-query.ts
new file mode 100644
index 00000000..69f5bc29
--- /dev/null
+++ b/apps/client/src/ee/page-permission/queries/page-permission-query.ts
@@ -0,0 +1,175 @@
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ IAddPagePermission,
+ IPageRestrictionInfo,
+ IRemovePagePermission,
+ IUpdatePagePermissionRole,
+} from "@/ee/page-permission/types/page-permission.types";
+import {
+ addPagePermission,
+ getPagePermissions,
+ getPageRestrictionInfo,
+ removePagePermission,
+ restrictPage,
+ unrestrictPage,
+ updatePagePermissionRole,
+} from "@/ee/page-permission/services/page-permission-service";
+import { IPage } from "@/features/page/types/page.types";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+export function usePageRestrictionInfoQuery(
+ pageId: string | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["page-restriction-info", pageId],
+ queryFn: () => getPageRestrictionInfo(pageId),
+ enabled: !!pageId,
+ });
+}
+
+export function usePagePermissionsQuery(pageId: string) {
+ return useInfiniteQuery({
+ queryKey: ["page-permissions", pageId],
+ queryFn: ({ pageParam }) => getPagePermissions(pageId, pageParam),
+ enabled: !!pageId,
+ //gcTime: 5000,
+ placeholderData: keepPreviousData,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
+ });
+}
+
+function updatePageRestrictionCache(
+ queryClient: ReturnType,
+ pageId: string,
+ hasRestriction: boolean,
+) {
+ queryClient.setQueriesData(
+ { queryKey: ["pages"] },
+ (old) => {
+ if (old?.id === pageId) {
+ return {
+ ...old,
+ permissions: { ...old.permissions, hasRestriction },
+ };
+ }
+ return old;
+ },
+ );
+ queryClient.invalidateQueries({
+ queryKey: ["page-restriction-info", pageId],
+ });
+ queryClient.removeQueries({
+ queryKey: ["page-permissions", pageId],
+ });
+}
+
+export function useRestrictPageMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (pageId) => restrictPage(pageId),
+ onSuccess: (_, pageId) => {
+ updatePageRestrictionCache(queryClient, pageId, true);
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage || t("Failed to restrict page"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUnrestrictPageMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (pageId) => unrestrictPage(pageId),
+ onSuccess: (_, pageId) => {
+ updatePageRestrictionCache(queryClient, pageId, false);
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage || t("Failed to remove page restriction"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useAddPagePermissionMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => addPagePermission(data),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: ["page-permissions", variables.pageId],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage || t("Failed to add permission"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useRemovePagePermissionMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => removePagePermission(data),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: ["page-permissions", variables.pageId],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage || t("Failed to remove permission"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdatePagePermissionRoleMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => updatePagePermissionRole(data),
+ onSuccess: (_, variables) => {
+ queryClient.refetchQueries({
+ queryKey: ["page-permissions", variables.pageId],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage || t("Failed to update permission"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/ee/page-permission/services/page-permission-service.ts b/apps/client/src/ee/page-permission/services/page-permission-service.ts
new file mode 100644
index 00000000..be4bd57c
--- /dev/null
+++ b/apps/client/src/ee/page-permission/services/page-permission-service.ts
@@ -0,0 +1,55 @@
+import api from "@/lib/api-client";
+import { IPagination } from "@/lib/types";
+import {
+ IAddPagePermission,
+ IPagePermissionMember,
+ IPageRestrictionInfo,
+ IRemovePagePermission,
+ IUpdatePagePermissionRole,
+} from "@/ee/page-permission/types/page-permission.types";
+
+export async function restrictPage(pageId: string): Promise {
+ await api.post("/pages/restrict", { pageId });
+}
+
+export async function addPagePermission(
+ data: IAddPagePermission,
+): Promise {
+ await api.post("/pages/add-permission", data);
+}
+
+export async function removePagePermission(
+ data: IRemovePagePermission,
+): Promise {
+ await api.post("/pages/remove-permission", data);
+}
+
+export async function updatePagePermissionRole(
+ data: IUpdatePagePermissionRole,
+): Promise {
+ await api.post("/pages/update-permission", data);
+}
+
+export async function unrestrictPage(pageId: string): Promise {
+ await api.post("/pages/remove-restriction", { pageId });
+}
+
+export async function getPagePermissions(
+ pageId: string,
+ cursor?: string,
+): Promise> {
+ const req = await api.post>(
+ "/pages/permissions",
+ { pageId, ...(cursor && { cursor }) },
+ );
+ return req.data;
+}
+
+export async function getPageRestrictionInfo(
+ pageId: string,
+): Promise {
+ const req = await api.post("/pages/permission-info", {
+ pageId,
+ });
+ return req.data;
+}
diff --git a/apps/client/src/ee/page-permission/types/page-permission-role-data.ts b/apps/client/src/ee/page-permission/types/page-permission-role-data.ts
new file mode 100644
index 00000000..057bc42a
--- /dev/null
+++ b/apps/client/src/ee/page-permission/types/page-permission-role-data.ts
@@ -0,0 +1,20 @@
+import { IRoleData } from "@/lib/types";
+import { PagePermissionRole } from "./page-permission.types";
+
+export const pagePermissionRoleData: IRoleData[] = [
+ {
+ label: "Can edit",
+ value: PagePermissionRole.WRITER,
+ description: "Can edit page and manage access",
+ },
+ {
+ label: "Can view",
+ value: PagePermissionRole.READER,
+ description: "Can only view page",
+ },
+];
+
+export function getPagePermissionRoleLabel(value: string): string | undefined {
+ const role = pagePermissionRoleData.find((item) => item.value === value);
+ return role ? role.label : undefined;
+}
diff --git a/apps/client/src/ee/page-permission/types/page-permission.types.ts b/apps/client/src/ee/page-permission/types/page-permission.types.ts
new file mode 100644
index 00000000..e4589269
--- /dev/null
+++ b/apps/client/src/ee/page-permission/types/page-permission.types.ts
@@ -0,0 +1,61 @@
+export enum PagePermissionRole {
+ READER = "reader",
+ WRITER = "writer",
+}
+
+export type IAddPagePermission = {
+ pageId: string;
+ role: PagePermissionRole;
+ userIds?: string[];
+ groupIds?: string[];
+};
+
+export type IRemovePagePermission = {
+ pageId: string;
+ userIds?: string[];
+ groupIds?: string[];
+};
+
+export type IUpdatePagePermissionRole = {
+ pageId: string;
+ role: PagePermissionRole;
+ userId?: string;
+ groupId?: string;
+};
+
+export type IPageRestrictionInfo = {
+ restrictionId?: string;
+ hasDirectRestriction: boolean;
+ hasInheritedRestriction: boolean;
+ inheritedFrom?: {
+ id: string;
+ slugId: string;
+ title: string;
+ };
+ userAccess: {
+ canView: boolean;
+ canEdit: boolean;
+ canManage: boolean;
+ };
+};
+
+type IPagePermissionBase = {
+ id: string;
+ name: string;
+ role: string;
+ createdAt: string;
+};
+
+export type IPagePermissionUser = IPagePermissionBase & {
+ type: "user";
+ email: string;
+ avatarUrl: string | null;
+};
+
+export type IPagePermissionGroup = IPagePermissionBase & {
+ type: "group";
+ memberCount: number;
+ isDefault: boolean;
+};
+
+export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
index 35c7d472..3b394ab0 100644
--- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
+++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
@@ -17,11 +17,6 @@ import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
-import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
-import {
- SpaceCaslAction,
- SpaceCaslSubject,
-} from "@/features/space/permissions/permissions.type.ts";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -38,14 +33,7 @@ function CommentListWithTabs() {
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
- const spaceRules = space?.membership?.permissions;
- const spaceAbility = useSpaceAbility(spaceRules);
-
-
- const canComment: boolean = spaceAbility.can(
- SpaceCaslAction.Manage,
- SpaceCaslSubject.Page
- );
+ const canComment = page?.permissions?.canEdit ?? false;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -54,14 +42,14 @@ function CommentListWithTabs() {
}
const parentComments = comments.items.filter(
- (comment: IComment) => comment.parentCommentId === null
+ (comment: IComment) => comment.parentCommentId === null,
);
const active = parentComments.filter(
- (comment: IComment) => !comment.resolvedAt
+ (comment: IComment) => !comment.resolvedAt,
);
const resolved = parentComments.filter(
- (comment: IComment) => comment.resolvedAt
+ (comment: IComment) => comment.resolvedAt,
);
return { activeComments: active, resolvedComments: resolved };
@@ -89,7 +77,7 @@ function CommentListWithTabs() {
setIsLoading(false);
}
},
- [createCommentMutation, page?.id]
+ [createCommentMutation, page?.id],
);
const renderComments = useCallback(
@@ -131,7 +119,7 @@ function CommentListWithTabs() {
)}
),
- [comments, handleAddReply, isLoading, space?.membership?.role]
+ [comments, handleAddReply, isLoading, space?.membership?.role],
);
if (isCommentsLoading) {
@@ -199,7 +187,14 @@ function CommentListWithTabs() {
}
return (
-
+
comments.items.filter(
- (comment: IComment) => comment.parentCommentId === parentId
+ (comment: IComment) => comment.parentCommentId === parentId,
),
- [comments.items]
+ [comments.items],
);
return (
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx
index 31318af5..f7df952e 100644
--- a/apps/client/src/features/editor/title-editor.tsx
+++ b/apps/client/src/features/editor/title-editor.tsx
@@ -171,11 +171,14 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
- // honor user default page edit mode preference
- if (userPageEditMode && titleEditor && editable) {
- if (userPageEditMode === PageEditMode.Edit) {
- titleEditor.setEditable(true);
- } else if (userPageEditMode === PageEditMode.Read) {
+ if (titleEditor) {
+ if (userPageEditMode && editable) {
+ if (userPageEditMode === PageEditMode.Edit) {
+ titleEditor.setEditable(true);
+ } else if (userPageEditMode === PageEditMode.Read) {
+ titleEditor.setEditable(false);
+ }
+ } else {
titleEditor.setEditable(false);
}
}
diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx
index f9510be6..c041dd48 100644
--- a/apps/client/src/features/notification/components/notification-item.tsx
+++ b/apps/client/src/features/notification/components/notification-item.tsx
@@ -46,6 +46,10 @@ export function NotificationItem({
return t("resolved a comment");
case "page.user_mention":
return t("mentioned you on a page");
+ case "page.permission_granted":
+ return notification.data?.role === "writer"
+ ? t("gave you edit access to a page")
+ : t("gave you view access to a page");
default:
return "";
}
diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts
index 2961f5fc..811805d0 100644
--- a/apps/client/src/features/notification/types/notification.types.ts
+++ b/apps/client/src/features/notification/types/notification.types.ts
@@ -2,7 +2,8 @@ export type NotificationType =
| "comment.user_mention"
| "comment.created"
| "comment.resolved"
- | "page.user_mention";
+ | "page.user_mention"
+ | "page.permission_granted";
export type INotification = {
id: string;
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 0561729c..2660b2ba 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
@@ -39,7 +39,7 @@ import { formattedDate } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
-import ShareModal from "@/features/share/components/share-modal.tsx";
+import { PageShareModal } from "@/ee/page-permission";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -75,7 +75,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && }
-
+
{
+ return data.canEdit === false;
+ }
+ }
+ disableDrop={
+ readOnly
+ ? true
+ : ({ parentNode }) => parentNode?.data?.canEdit === false
+ }
+ disableEdit={readOnly ? true : (data) => data.canEdit === false}
{...controllers}
width={width}
height={rootElement.current.clientHeight}
@@ -417,7 +423,9 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) {
)
}
- readOnly={tree.props.disableEdit as boolean}
+ readOnly={
+ tree.props.disableEdit === true || node.data.canEdit === false
+ }
removeEmojiAction={handleRemoveEmoji}
/>
@@ -427,7 +435,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps
) {
- {!tree.props.disableEdit && (
+ {tree.props.disableEdit !== true && node.data.canEdit !== false && (
- {!(treeApi.props.disableEdit as boolean) && (
- <>
- }
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDuplicatePage();
- }}
- >
- {t("Duplicate")}
-
+ {treeApi.props.disableEdit !== true &&
+ node.data.canEdit !== false && (
+ <>
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDuplicatePage();
+ }}
+ >
+ {t("Duplicate")}
+
- }
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- openMovePageModal();
- }}
- >
- {t("Move")}
-
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openMovePageModal();
+ }}
+ >
+ {t("Move")}
+
- }
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- openCopyPageModal();
- }}
- >
- {t("Copy to space")}
-
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openCopyPageModal();
+ }}
+ >
+ {t("Copy to space")}
+
-
- }
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
- }}
- >
- {t("Move to trash")}
-
- >
- )}
+
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
+ }}
+ >
+ {t("Move to trash")}
+
+ >
+ )}
diff --git a/apps/client/src/features/page/tree/types.ts b/apps/client/src/features/page/tree/types.ts
index b48822c1..6c60b157 100644
--- a/apps/client/src/features/page/tree/types.ts
+++ b/apps/client/src/features/page/tree/types.ts
@@ -7,5 +7,6 @@ export type SpaceTreeNode = {
spaceId: string;
parentPageId: string;
hasChildren: boolean;
+ canEdit?: boolean;
children: SpaceTreeNode[];
};
diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts
index 8ec1b884..0c42f9b9 100644
--- a/apps/client/src/features/page/tree/utils/utils.ts
+++ b/apps/client/src/features/page/tree/utils/utils.ts
@@ -24,6 +24,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
+ canEdit: page.canEdit ?? page.permissions?.canEdit,
children: [],
};
});
diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts
index a19809ce..216a6105 100644
--- a/apps/client/src/features/page/types/page.types.ts
+++ b/apps/client/src/features/page/types/page.types.ts
@@ -18,10 +18,15 @@ export interface IPage {
deletedAt: Date;
position: string;
hasChildren: boolean;
+ canEdit?: boolean;
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial;
+ permissions?: {
+ canEdit: boolean;
+ hasRestriction: boolean;
+ };
}
interface ICreator {
diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx
index a7feda26..b5025bbe 100644
--- a/apps/client/src/features/share/components/share-modal.tsx
+++ b/apps/client/src/features/share/components/share-modal.tsx
@@ -69,19 +69,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const handleChange = async (event: React.ChangeEvent) => {
const value = event.currentTarget.checked;
+ setIsPagePublic(value);
- if (value) {
- createShareMutation.mutateAsync({
- pageId: pageId,
- includeSubPages: true,
- searchIndexing: false,
- });
- setIsPagePublic(value);
- } else {
- if (share && share.id) {
- deleteShareMutation.mutateAsync(share.id);
- setIsPagePublic(value);
+ try {
+ if (value) {
+ await createShareMutation.mutateAsync({
+ pageId: pageId,
+ includeSubPages: true,
+ searchIndexing: false,
+ });
+ } else if (share && share.id) {
+ await deleteShareMutation.mutateAsync(share.id);
}
+ } catch {
+ setIsPagePublic(!value);
}
};
@@ -89,20 +90,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
event: React.ChangeEvent,
) => {
const value = event.currentTarget.checked;
- updateShareMutation.mutateAsync({
- shareId: share.id,
- includeSubPages: value,
- });
+ try {
+ await updateShareMutation.mutateAsync({
+ shareId: share.id,
+ includeSubPages: value,
+ });
+ } catch {
+ // query invalidation will revert the UI
+ }
};
const handleIndexSearchChange = async (
event: React.ChangeEvent,
) => {
const value = event.currentTarget.checked;
- updateShareMutation.mutateAsync({
- shareId: share.id,
- searchIndexing: value,
- });
+ try {
+ await updateShareMutation.mutateAsync({
+ shareId: share.id,
+ searchIndexing: value,
+ });
+ } catch {
+ // query invalidation will revert the UI
+ }
};
const shareLink = useMemo(
diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts
index 1ee31bd5..c6e61ff9 100644
--- a/apps/client/src/features/share/queries/share-query.ts
+++ b/apps/client/src/features/share/queries/share-query.ts
@@ -90,7 +90,10 @@ export function useCreateShareMutation() {
});
},
onError: (error) => {
- notifications.show({ message: t("Failed to share page"), color: "red" });
+ notifications.show({
+ message: error?.["response"]?.data?.message || t("Failed to share page"),
+ color: "red",
+ });
},
});
}
diff --git a/apps/client/src/features/space/components/multi-member-select.tsx b/apps/client/src/features/space/components/multi-member-select.tsx
index 4a0a7fe8..ab077b49 100644
--- a/apps/client/src/features/space/components/multi-member-select.tsx
+++ b/apps/client/src/features/space/components/multi-member-select.tsx
@@ -9,6 +9,7 @@ import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
+ value?: string[];
onChange: (value: string[]) => void;
}
@@ -33,7 +34,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
-export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
+export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
@@ -85,6 +86,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
return (
-
+
{t("Settings")}
@@ -63,7 +63,7 @@ export default function SpaceSettingsModal({
-
+
-
diff --git a/apps/client/src/features/user/components/page-state-pref.tsx b/apps/client/src/features/user/components/page-state-pref.tsx
index 283dc6bf..712f5152 100644
--- a/apps/client/src/features/user/components/page-state-pref.tsx
+++ b/apps/client/src/features/user/components/page-state-pref.tsx
@@ -40,12 +40,17 @@ export function PageStateSegmentedControl({
const [value, setValue] = useState(pageEditMode);
const handleChange = useCallback(
- async (value: string) => {
- const updatedUser = await updateUser({ pageEditMode: value });
- setValue(value);
- setUser(updatedUser);
+ async (newValue: string) => {
+ const prevValue = value;
+ setValue(newValue);
+ try {
+ const updatedUser = await updateUser({ pageEditMode: newValue });
+ setUser(updatedUser);
+ } catch {
+ setValue(prevValue);
+ }
},
- [user, setUser],
+ [value, setUser],
);
useEffect(() => {
diff --git a/apps/client/src/features/user/components/page-width-pref.tsx b/apps/client/src/features/user/components/page-width-pref.tsx
index c1ce4816..81caca82 100644
--- a/apps/client/src/features/user/components/page-width-pref.tsx
+++ b/apps/client/src/features/user/components/page-width-pref.tsx
@@ -39,9 +39,13 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const handleChange = async (event: React.ChangeEvent
) => {
const value = event.currentTarget.checked;
- const updatedUser = await updateUser({ fullPageWidth: value });
setChecked(value);
- setUser(updatedUser);
+ try {
+ const updatedUser = await updateUser({ fullPageWidth: value });
+ setUser(updatedUser);
+ } catch {
+ setChecked(!value);
+ }
};
return (
diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx
index 0e4c3314..0583dc34 100644
--- a/apps/client/src/main.tsx
+++ b/apps/client/src/main.tsx
@@ -42,7 +42,6 @@ if (isCloud() && isPostHogEnabled) {
});
}
-
const container = document.getElementById("root") as HTMLElement;
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx
index 449f1e15..fc564b4b 100644
--- a/apps/client/src/pages/page/page.tsx
+++ b/apps/client/src/pages/page/page.tsx
@@ -6,11 +6,6 @@ import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
-import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
-import {
- SpaceCaslAction,
- SpaceCaslSubject,
-} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
@@ -18,7 +13,6 @@ import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
-
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
@@ -58,8 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
- const spaceRules = space?.membership?.permissions;
- const spaceAbility = useSpaceAbility(spaceRules);
+ const canEdit = page?.permissions?.canEdit ?? false;
if (isLoading) {
return <>>;
@@ -101,12 +94,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
{`${page?.icon || ""} ${page?.title || t("untitled")}`}
-
+
diff --git a/apps/client/src/theme.ts b/apps/client/src/theme.ts
index 45c3474b..e114893d 100644
--- a/apps/client/src/theme.ts
+++ b/apps/client/src/theme.ts
@@ -2,6 +2,7 @@ import {
createTheme,
CSSVariablesResolver,
MantineColorsTuple,
+ Tabs,
} from "@mantine/core";
const blue: MantineColorsTuple = [
@@ -35,6 +36,17 @@ export const theme = createTheme({
blue,
red,
},
+ components: {
+ Tabs: Tabs.extend({
+ vars: (theme, props) => ({
+ root: {
+ ...(props.color === "dark" && {
+ "--tabs-color": "var(--mantine-color-dark-default)",
+ }),
+ },
+ }),
+ }),
+ },
/***
components: {
ActionIcon: ActionIcon.extend({
diff --git a/apps/server/package.json b/apps/server/package.json
index 65cb8e59..0e421a1d 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -39,10 +39,12 @@
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
+ "@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
+ "@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.13",
@@ -156,6 +158,11 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
- "testEnvironment": "node"
+ "testEnvironment": "node",
+ "moduleNameMapper": {
+ "^@docmost/db/(.*)$": "/database/$1",
+ "^@docmost/transactional/(.*)$": "/integrations/transactional/$1",
+ "^@docmost/ee/(.*)$": "/ee/$1"
+ }
}
}
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 8036b849..13e28e18 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
+import { EnvironmentService } from './integrations/environment/environment.service';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -18,6 +19,8 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
+import { CacheModule } from '@nestjs/cache-manager';
+import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = [];
@@ -43,6 +46,18 @@ try {
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
+ CacheModule.registerAsync({
+ isGlobal: true,
+ useFactory: async (environmentService: EnvironmentService) => {
+ const redisUrl = environmentService.getRedisUrl();
+
+ return {
+ ttl: 5 * 1000,
+ stores: [new KeyvRedis(redisUrl)],
+ };
+ },
+ inject: [EnvironmentService],
+ }),
CollaborationModule,
WsModule,
QueueModule,
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index 9f173d44..d0cb2d9e 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -33,10 +33,10 @@ import {
Subpages,
Highlight,
UniqueID,
- addUniqueIdsToDoc,
- htmlToMarkdown,
Columns,
Column,
+ addUniqueIdsToDoc,
+ htmlToMarkdown,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts
index 04a360f7..4045601d 100644
--- a/apps/server/src/collaboration/extensions/authentication.extension.ts
+++ b/apps/server/src/collaboration/extensions/authentication.extension.ts
@@ -9,6 +9,7 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
@@ -23,6 +24,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -52,7 +54,7 @@ export class AuthenticationExtension implements Extension {
const page = await this.pageRepo.findById(pageId);
if (!page) {
- this.logger.warn(`Page not found: ${pageId}`);
+ this.logger.debug(`Page not found: ${pageId}`);
throw new NotFoundException('Page not found');
}
@@ -68,9 +70,34 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
- if (userSpaceRole === SpaceRole.READER) {
+ // Check page-level permissions
+ const { hasAnyRestriction, canAccess, canEdit } =
+ await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
+
+ if (hasAnyRestriction) {
+ if (!canAccess) {
+ this.logger.warn(
+ `User ${user.id} denied page-level access to page: ${pageId}`,
+ );
+ throw new UnauthorizedException();
+ }
+
+ if (!canEdit) {
+ data.connectionConfig.readOnly = true;
+ this.logger.debug(
+ `User ${user.id} granted readonly access to restricted page: ${pageId}`,
+ );
+ }
+ } else {
+ // No restrictions - use space-level permissions
+ if (userSpaceRole === SpaceRole.READER) {
+ data.connectionConfig.readOnly = true;
+ this.logger.debug(`User granted readonly access to page: ${pageId}`);
+ }
+ }
+
+ if (page.deletedAt) {
data.connectionConfig.readOnly = true;
- this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
diff --git a/apps/server/src/common/helpers/types/permission.ts b/apps/server/src/common/helpers/types/permission.ts
index 1f4f8664..5493bdb4 100644
--- a/apps/server/src/common/helpers/types/permission.ts
+++ b/apps/server/src/common/helpers/types/permission.ts
@@ -14,3 +14,12 @@ export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see
}
+
+export enum PageAccessLevel {
+ RESTRICTED = 'restricted', // only specific users/groups can view or edit
+}
+
+export enum PagePermissionRole {
+ READER = 'reader', // can only view content and descendants
+ WRITER = 'writer', // can edit content, descendants, and add new users to permission
+}
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index 73c9699a..99d2093d 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -53,6 +53,7 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
+import { PageAccessService } from '../page/page-access/page-access.service';
@Controller()
export class AttachmentController {
@@ -67,6 +68,7 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
+ private readonly pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +113,7 @@ export class AttachmentController {
throw new NotFoundException('Page not found');
}
- const spaceAbility = await this.spaceAbility.createForUser(
- user,
- page.spaceId,
- );
- if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanEdit(page, user);
const spaceId = page.spaceId;
@@ -172,15 +168,13 @@ export class AttachmentController {
throw new NotFoundException();
}
- const spaceAbility = await this.spaceAbility.createForUser(
- user,
- attachment.spaceId,
- );
-
- if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
+ const page = await this.pageRepo.findById(attachment.pageId);
+ if (!page) {
+ throw new NotFoundException();
}
+ await this.pageAccessService.validateCanView(page, user);
+
try {
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts
index 5ced1656..1872d56e 100644
--- a/apps/server/src/core/comment/comment.controller.ts
+++ b/apps/server/src/core/comment/comment.controller.ts
@@ -24,6 +24,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
+import { PageAccessService } from '../page/page-access/page-access.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -33,6 +34,7 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
+ private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -47,10 +49,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.create(
{
@@ -75,10 +74,8 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanView(page, user);
+
return this.commentService.findByPageId(page.id, pagination);
}
@@ -90,13 +87,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
- const ability = await this.spaceAbility.createForUser(
- user,
- comment.spaceId,
- );
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
+ const page = await this.pageRepo.findById(comment.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
}
+
+ await this.pageAccessService.validateCanView(page, user);
+
return comment;
}
@@ -108,18 +105,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
- const ability = await this.spaceAbility.createForUser(
- user,
- comment.spaceId,
- );
-
- // must be a space member with edit permission
- if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
- throw new ForbiddenException(
- 'You must have space edit permission to edit comments',
- );
+ const page = await this.pageRepo.findById(comment.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
}
+ await this.pageAccessService.validateCanEdit(page, user);
+
return this.commentService.update(comment, dto, user);
}
@@ -131,41 +123,27 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
- const ability = await this.spaceAbility.createForUser(
- user,
- comment.spaceId,
- );
-
- // must be a space member with edit permission
- if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
+ const page = await this.pageRepo.findById(comment.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
}
+ // Check page-level edit permission first
+ await this.pageAccessService.validateCanEdit(page, user);
+
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
- /*
- // Check if comment has children from other users
- const hasChildrenFromOthers =
- await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
-
- // Owner can delete if no children from other users
- if (!hasChildrenFromOthers) {
- await this.commentRepo.deleteComment(comment.id);
- return;
- }
-
- // If has children from others, only space admin can delete
- if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
- throw new ForbiddenException(
- 'Only space admins can delete comments with replies from other users',
- );
- }*/
await this.commentRepo.deleteComment(comment.id);
return;
}
+ const ability = await this.spaceAbility.createForUser(
+ user,
+ comment.spaceId,
+ );
+
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts
index f8b75cd0..df95bff2 100644
--- a/apps/server/src/core/core.module.ts
+++ b/apps/server/src/core/core.module.ts
@@ -14,6 +14,7 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
+import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
@@ -31,6 +32,7 @@ import { WatcherModule } from './watcher/watcher.module';
SpaceModule,
GroupModule,
CaslModule,
+ PageAccessModule,
ShareModule,
NotificationModule,
WatcherModule,
diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts
index 037d099e..56d2ecad 100644
--- a/apps/server/src/core/notification/notification.constants.ts
+++ b/apps/server/src/core/notification/notification.constants.ts
@@ -3,6 +3,7 @@ export const NotificationType = {
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
+ PAGE_PERMISSION_GRANTED: 'page.permission_granted',
} as const;
export type NotificationType =
diff --git a/apps/server/src/core/notification/notification.module.ts b/apps/server/src/core/notification/notification.module.ts
index 7995693b..a142eaf8 100644
--- a/apps/server/src/core/notification/notification.module.ts
+++ b/apps/server/src/core/notification/notification.module.ts
@@ -4,10 +4,9 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
-import { WsModule } from '../../ws/ws.module';
@Module({
- imports: [WsModule],
+ imports: [],
controllers: [NotificationController],
providers: [
NotificationService,
diff --git a/apps/server/src/core/notification/notification.processor.ts b/apps/server/src/core/notification/notification.processor.ts
index e9c0d1f9..f7c8b577 100644
--- a/apps/server/src/core/notification/notification.processor.ts
+++ b/apps/server/src/core/notification/notification.processor.ts
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
+ IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
@@ -33,7 +34,8 @@ export class NotificationProcessor
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
- | IPageMentionNotificationJob,
+ | IPageMentionNotificationJob
+ | IPermissionGrantedNotificationJob,
void
>,
): Promise {
@@ -66,6 +68,14 @@ export class NotificationProcessor
break;
}
+ case QueueJob.PAGE_PERMISSION_GRANTED: {
+ await this.pageNotificationService.processPermissionGranted(
+ job.data as IPermissionGrantedNotificationJob,
+ appUrl,
+ );
+ break;
+ }
+
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
diff --git a/apps/server/src/core/notification/services/comment.notification.ts b/apps/server/src/core/notification/services/comment.notification.ts
index 5c6b5bd5..e75da302 100644
--- a/apps/server/src/core/notification/services/comment.notification.ts
+++ b/apps/server/src/core/notification/services/comment.notification.ts
@@ -8,6 +8,7 @@ import {
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
@@ -22,6 +23,7 @@ export class CommentNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
) {}
@@ -59,12 +61,19 @@ export class CommentNotificationService {
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
- const usersWithAccess =
+ const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
+ const usersWithPageAccess =
+ await this.pagePermissionRepo.getUserIdsWithPageAccess(
+ pageId,
+ [...usersWithSpaceAccess],
+ );
+ const usersWithAccess = new Set(usersWithPageAccess);
+
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -146,6 +155,13 @@ export class CommentNotificationService {
return;
}
+ const hasPageAccess =
+ await this.pagePermissionRepo.getUserIdsWithPageAccess(
+ pageId,
+ [commentCreatorId],
+ );
+ if (hasPageAccess.length === 0) return;
+
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
diff --git a/apps/server/src/core/notification/services/page.notification.ts b/apps/server/src/core/notification/services/page.notification.ts
index 40bd3544..a8d951dd 100644
--- a/apps/server/src/core/notification/services/page.notification.ts
+++ b/apps/server/src/core/notification/services/page.notification.ts
@@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
-import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
+import {
+ IPageMentionNotificationJob,
+ IPermissionGrantedNotificationJob,
+} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
+import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
@@ -14,6 +19,7 @@ export class PageNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -28,12 +34,19 @@ export class PageNotificationService {
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
- const usersWithAccess =
+ const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
+ const usersWithPageAccess =
+ await this.pagePermissionRepo.getUserIdsWithPageAccess(
+ pageId,
+ [...usersWithSpaceAccess],
+ );
+ const usersWithAccess = new Set(usersWithPageAccess);
+
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
@@ -97,6 +110,52 @@ export class PageNotificationService {
}
}
+ async processPermissionGranted(
+ data: IPermissionGrantedNotificationJob,
+ appUrl: string,
+ ) {
+ const { userIds, pageId, spaceId, workspaceId, actorId, role } = data;
+
+ if (userIds.length === 0) return;
+
+ const usersWithSpaceAccess =
+ await this.spaceMemberRepo.getUserIdsWithSpaceAccess(userIds, spaceId);
+
+ if (usersWithSpaceAccess.size === 0) return;
+
+ const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
+ if (!context) return;
+
+ const { actor, pageTitle, basePageUrl } = context;
+ const accessLabel = role === 'writer' ? 'edit' : 'view';
+
+ for (const userId of usersWithSpaceAccess) {
+ const notification = await this.notificationService.create({
+ userId,
+ workspaceId,
+ type: NotificationType.PAGE_PERMISSION_GRANTED,
+ actorId,
+ pageId,
+ spaceId,
+ data: { role },
+ });
+
+ const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
+
+ await this.notificationService.queueEmail(
+ userId,
+ notification.id,
+ subject,
+ PermissionGrantedEmail({
+ actorName: actor.name,
+ pageTitle,
+ pageUrl: basePageUrl,
+ accessLabel,
+ }),
+ );
+ }
+ }
+
private async getPageContext(
actorId: string,
pageId: string,
diff --git a/apps/server/src/core/page/page-access/page-access.module.ts b/apps/server/src/core/page/page-access/page-access.module.ts
new file mode 100644
index 00000000..ac64ea49
--- /dev/null
+++ b/apps/server/src/core/page/page-access/page-access.module.ts
@@ -0,0 +1,9 @@
+import { Global, Module } from '@nestjs/common';
+import { PageAccessService } from './page-access.service';
+
+@Global()
+@Module({
+ providers: [PageAccessService],
+ exports: [PageAccessService],
+})
+export class PageAccessModule {}
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
new file mode 100644
index 00000000..07395ed4
--- /dev/null
+++ b/apps/server/src/core/page/page-access/page-access.service.ts
@@ -0,0 +1,102 @@
+import { ForbiddenException, Injectable } from '@nestjs/common';
+import { Page, User } from '@docmost/db/types/entity.types';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from '../../casl/interfaces/space-ability.type';
+
+@Injectable()
+export class PageAccessService {
+ constructor(
+ private readonly pagePermissionRepo: PagePermissionRepo,
+ private readonly spaceAbility: SpaceAbilityFactory,
+ ) {}
+
+ /**
+ * Validate user can view page, throws ForbiddenException if not.
+ * If page has restrictions: page-level permission determines access.
+ * If no restrictions: space-level permission determines access.
+ */
+ async validateCanView(page: Page, user: User): Promise {
+ // TODO: cache by pageId and userId.
+ const ability = await this.spaceAbility.createForUser(user, page.spaceId);
+
+ // User must be at least a space member
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+
+ const canAccess = await this.pagePermissionRepo.canUserAccessPage(
+ user.id,
+ page.id,
+ );
+ if (!canAccess) {
+ throw new ForbiddenException();
+ }
+ }
+
+ /**
+ * Validate user can view page AND return effective canEdit permission.
+ * Combines access check + edit permission in a single query pass.
+ */
+ async validateCanViewWithPermissions(
+ page: Page,
+ user: User,
+ ): Promise<{ canEdit: boolean; hasRestriction: boolean }> {
+ const ability = await this.spaceAbility.createForUser(user, page.spaceId);
+
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+
+ const { hasAnyRestriction, canAccess, canEdit } =
+ await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
+
+ if (hasAnyRestriction && !canAccess) {
+ throw new ForbiddenException();
+ }
+
+ return {
+ canEdit: hasAnyRestriction
+ ? canEdit
+ : ability.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
+ hasRestriction: hasAnyRestriction,
+ };
+ }
+
+ /**
+ * Validate user can edit page, throws ForbiddenException if not.
+ * If page has restrictions: page-level writer permission determines access.
+ * If no restrictions: space-level edit permission determines access.
+ */
+ async validateCanEdit(
+ page: Page,
+ user: User,
+ ): Promise<{ hasRestriction: boolean }> {
+ const ability = await this.spaceAbility.createForUser(user, page.spaceId);
+
+ // User must be at least a space member
+ if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+
+ const { hasAnyRestriction, canEdit } =
+ await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
+
+ if (hasAnyRestriction) {
+ // Page has restrictions - use page-level permission
+ if (!canEdit) {
+ throw new ForbiddenException();
+ }
+ } else {
+ // No restrictions - use space-level permission
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
+ }
+
+ return { hasRestriction: hasAnyRestriction };
+ }
+}
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index e1227f28..28962293 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
+import { PageAccessService } from './page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -48,6 +49,7 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
+ private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -65,10 +67,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ const { canEdit, hasRestriction } =
+ await this.pageAccessService.validateCanViewWithPermissions(page, user);
+
+ const permissions = { canEdit, hasRestriction };
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
@@ -78,10 +80,11 @@ export class PageController {
return {
...page,
content: contentOutput,
+ permissions,
};
}
- return page;
+ return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -91,12 +94,28 @@ export class PageController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
- const ability = await this.spaceAbility.createForUser(
- user,
- createPageDto.spaceId,
- );
- if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
+ if (createPageDto.parentPageId) {
+ // Creating under a parent page - check edit permission on parent
+ const parentPage = await this.pageRepo.findById(
+ createPageDto.parentPageId,
+ );
+ if (
+ !parentPage ||
+ parentPage.deletedAt ||
+ parentPage.spaceId !== createPageDto.spaceId
+ ) {
+ throw new NotFoundException('Parent page not found');
+ }
+ await this.pageAccessService.validateCanEdit(parentPage, user);
+ } else {
+ // Creating at root level - require space-level permission
+ const ability = await this.spaceAbility.createForUser(
+ user,
+ createPageDto.spaceId,
+ );
+ if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
+ throw new ForbiddenException();
+ }
}
const page = await this.pageService.create(
@@ -105,6 +124,11 @@ export class PageController {
createPageDto,
);
+ const { canEdit, hasRestriction } =
+ await this.pageAccessService.validateCanViewWithPermissions(page, user);
+
+ const permissions = { canEdit, hasRestriction };
+
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -114,10 +138,10 @@ export class PageController {
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
- return { ...page, content: contentOutput };
+ return { ...page, content: contentOutput, permissions };
}
- return page;
+ return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -129,10 +153,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ const { hasRestriction } =
+ await this.pageAccessService.validateCanEdit(page, user);
const updatedPage = await this.pageService.update(
page,
@@ -140,6 +162,8 @@ export class PageController {
user,
);
+ const permissions = { canEdit: true, hasRestriction };
+
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
@@ -149,10 +173,10 @@ export class PageController {
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
- return { ...updatedPage, content: contentOutput };
+ return { ...updatedPage, content: contentOutput, permissions };
}
- return updatedPage;
+ return { ...updatedPage, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -179,10 +203,9 @@ export class PageController {
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else {
- // Soft delete requires page manage permissions
- if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ // User with edit permission can delete
+ await this.pageAccessService.validateCanEdit(page, user);
+
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
@@ -204,11 +227,18 @@ export class PageController {
throw new NotFoundException('Page not found');
}
+ //Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
+ // so page is virtually lost. Fix.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
+ //TODO: can users with page level edit, but no space level edit restore pages they can edit?
+
+ // Check page-level edit permission (if restoring to a restricted ancestor)
+ await this.pageAccessService.validateCanEdit(page, user);
+
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
@@ -235,6 +265,7 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
+ user.id,
pagination,
);
}
@@ -261,6 +292,7 @@ export class PageController {
return this.pageService.getDeletedSpacePages(
deletedPageDto.spaceId,
+ user.id,
pagination,
);
}
@@ -278,10 +310,7 @@ export class PageController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanView(page, user);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -297,13 +326,14 @@ export class PageController {
throw new NotFoundException('Page history not found');
}
- const ability = await this.spaceAbility.createForUser(
- user,
- history.spaceId,
- );
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
+ // Get the page to check permissions
+ const page = await this.pageRepo.findById(history.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
}
+
+ await this.pageAccessService.validateCanView(page, user);
+
return history;
}
@@ -335,7 +365,18 @@ export class PageController {
throw new ForbiddenException();
}
- return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
+ const spaceCanEdit = ability.can(
+ SpaceCaslAction.Edit,
+ SpaceCaslSubject.Page,
+ );
+
+ return this.pageService.getSidebarPages(
+ spaceId,
+ pagination,
+ dto.pageId,
+ user.id,
+ spaceCanEdit,
+ );
}
@HttpCode(HttpStatus.OK)
@@ -365,7 +406,11 @@ export class PageController {
throw new ForbiddenException();
}
- return this.pageService.movePageToSpace(movedPage, dto.spaceId);
+ // Check page-level edit permission on the source page
+ await this.pageAccessService.validateCanEdit(movedPage, user);
+
+ // Moves only accessible pages; inaccessible child pages become root pages in original space
+ return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
}
@HttpCode(HttpStatus.OK)
@@ -376,6 +421,10 @@ export class PageController {
throw new NotFoundException('Page to copy not found');
}
+ // Check page-level view permission on the source page (need to read to copy)
+ // Inaccessible child branches are automatically skipped during duplication
+ await this.pageAccessService.validateCanView(copiedPage, user);
+
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -418,10 +467,23 @@ export class PageController {
user,
movedPage.spaceId,
);
+
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
+ // Check page-level edit permission
+ await this.pageAccessService.validateCanEdit(movedPage, user);
+
+ // If moving to a new parent, check permission on the target parent
+ if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
+ const targetParent = await this.pageRepo.findById(dto.parentPageId);
+ if (!targetParent || targetParent.deletedAt) {
+ throw new NotFoundException('Target parent page not found');
+ }
+ await this.pageAccessService.validateCanEdit(targetParent, user);
+ }
+
return this.pageService.movePage(dto, movedPage);
}
@@ -433,10 +495,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanView(page, user);
+
return this.pageService.getPageBreadCrumbs(page.id);
}
}
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index 0f9957ff..2c68d3ef 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -7,6 +7,7 @@ import {
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
@@ -48,6 +49,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
+import { sql } from 'kysely';
@Injectable()
export class PageService {
@@ -55,6 +57,7 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
+ private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -92,7 +95,11 @@ export class PageService {
createPageDto.parentPageId,
);
- if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
+ if (
+ !parentPage ||
+ parentPage.deletedAt ||
+ parentPage.spaceId !== createPageDto.spaceId
+ ) {
throw new NotFoundException('Parent page not found');
}
@@ -262,6 +269,8 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
+ userId?: string,
+ spaceCanEdit?: boolean,
): Promise & { hasChildren: boolean }>> {
let query = this.db
.selectFrom('pages')
@@ -286,8 +295,8 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
- return executeWithCursorPagination(query, {
- perPage: 250,
+ const result = await executeWithCursorPagination(query, {
+ perPage: 200,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
@@ -303,10 +312,97 @@ export class PageService {
id: cursor.id,
}),
});
+
+ if (userId && result.items.length > 0) {
+ const hasRestrictions =
+ await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
+
+ if (!hasRestrictions) {
+ result.items = result.items.map((p: any) => ({
+ ...p,
+ canEdit: spaceCanEdit ?? true,
+ }));
+ } else {
+ const pageIds = result.items.map((p: any) => p.id);
+
+ const accessiblePages =
+ await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
+ pageIds,
+ userId,
+ );
+
+ const permissionMap = new Map(
+ accessiblePages.map((p) => [p.id, p.canEdit]),
+ );
+
+ result.items = result.items
+ .filter((p: any) => permissionMap.has(p.id))
+ .map((p: any) => ({
+ ...p,
+ canEdit: permissionMap.get(p.id) && (spaceCanEdit ?? true),
+ }));
+
+ const pagesWithChildren = result.items.filter(
+ (p: any) => p.hasChildren,
+ );
+ if (pagesWithChildren.length > 0) {
+ const parentIds = pagesWithChildren.map((p: any) => p.id);
+ const parentsWithAccessibleChildren =
+ await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
+ parentIds,
+ userId,
+ );
+ const hasAccessibleChildrenSet = new Set(
+ parentsWithAccessibleChildren,
+ );
+
+ result.items = result.items.map((p: any) => ({
+ ...p,
+ hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
+ }));
+ }
+ }
+ }
+
+ return result;
}
- async movePageToSpace(rootPage: Page, spaceId: string) {
+ async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
+ const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
+ includeContent: false,
+ });
+
+ // Filter to only accessible pages while maintaining tree integrity
+ const accessiblePages = await this.filterAccessibleTreePages(
+ allPages,
+ rootPage.id,
+ userId,
+ rootPage.spaceId,
+ );
+ const accessibleIds = new Set(accessiblePages.map((p) => p.id));
+
+ // Find inaccessible pages whose parent is being moved - these need to be orphaned
+ const pagesToOrphan = allPages.filter(
+ (p) =>
+ !accessibleIds.has(p.id) &&
+ p.parentPageId &&
+ accessibleIds.has(p.parentPageId),
+ );
+
await executeTx(this.db, async (trx) => {
+ // Orphan inaccessible child pages (make them root pages in original space)
+ for (const page of pagesToOrphan) {
+ const orphanPosition = await this.nextPagePosition(
+ rootPage.spaceId,
+ null,
+ );
+ await this.pageRepo.updatePage(
+ { parentPageId: null, position: orphanPosition },
+ page.id,
+ trx,
+ );
+ }
+
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
@@ -314,48 +410,54 @@ export class PageService {
rootPage.id,
trx,
);
- const pageIds = await this.pageRepo
- .getPageAndDescendants(rootPage.id, { includeContent: false })
- .then((pages) => pages.map((page) => page.id));
- // The first id is the root page id
- if (pageIds.length > 1) {
- // Update sub pages
+
+ const pageIdsToMove = accessiblePages.map((p) => p.id);
+
+ if (pageIdsToMove.length > 1) {
+ // Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
- pageIds.filter((id) => id !== rootPage.id),
+ pageIdsToMove.filter((id) => id !== rootPage.id),
trx,
);
}
- if (pageIds.length > 0) {
+ if (pageIdsToMove.length > 0) {
+ // Clear page-level permissions - moved pages inherit destination space permissions
+ // (page_permissions cascade deletes via foreign key)
+ await trx
+ .deleteFrom('pageAccess')
+ .where('pageId', 'in', pageIdsToMove)
+ .execute();
+
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
- .where('pageId', 'in', pageIds)
+ .where('pageId', 'in', pageIdsToMove)
.execute();
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
- .where('pageId', 'in', pageIds)
+ .where('pageId', 'in', pageIdsToMove)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
- pageIds,
+ pageIdsToMove,
trx,
);
// Update watchers and remove those without access to new space
- await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
+ await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
- pageId: pageIds,
+ pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
}
@@ -381,10 +483,18 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId);
}
- const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
+ const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
+ // Filter to only accessible pages while maintaining tree integrity
+ const pages = await this.filterAccessibleTreePages(
+ allPages,
+ rootPage.id,
+ authUser.id,
+ rootPage.spaceId,
+ );
+
const pageMap = new Map();
pages.forEach((page) => {
pageMap.set(page.id, {
@@ -592,7 +702,11 @@ export class PageService {
// changing the page's parent
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
- if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
+ if (
+ !parentPage ||
+ parentPage.deletedAt ||
+ parentPage.spaceId !== movedPage.spaceId
+ ) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
@@ -623,7 +737,6 @@ export class PageService {
'spaceId',
'deletedAt',
])
- .select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
@@ -639,30 +752,21 @@ export class PageService {
'p.spaceId',
'p.deletedAt',
])
- .select(
- exp
- .selectFrom('pages as child')
- .select((eb) =>
- eb
- .case()
- .when(eb.fn.countAll(), '>', 0)
- .then(true)
- .else(false)
- .end()
- .as('count'),
- )
- .whereRef('child.parentPageId', '=', 'id')
- .where('child.deletedAt', 'is', null)
- .limit(1)
- .as('hasChildren'),
- )
- //.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
- .selectAll()
+ .selectAll('page_ancestors')
+ .select((eb) =>
+ eb.exists(
+ eb
+ .selectFrom('pages as child')
+ .select(sql`1`.as('one'))
+ .whereRef('child.parentPageId', '=', 'page_ancestors.id')
+ .where('child.deletedAt', 'is', null),
+ ).as('hasChildren'),
+ )
.execute();
return ancestors.reverse();
@@ -670,23 +774,72 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
+ userId: string,
pagination: PaginationOptions,
): Promise> {
- return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
+ const result = await this.pageRepo.getRecentPagesInSpace(
+ spaceId,
+ pagination,
+ );
+
+ if (result.items.length > 0) {
+ const pageIds = result.items.map((p) => p.id);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId,
+ spaceId,
+ });
+ const accessibleSet = new Set(accessibleIds);
+ result.items = result.items.filter((p) => accessibleSet.has(p.id));
+ }
+
+ return result;
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise> {
- return this.pageRepo.getRecentPages(userId, pagination);
+ const result = await this.pageRepo.getRecentPages(userId, pagination);
+
+ if (result.items.length > 0) {
+ const pageIds = result.items.map((p) => p.id);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId,
+ });
+ const accessibleSet = new Set(accessibleIds);
+ result.items = result.items.filter((p) => accessibleSet.has(p.id));
+ }
+
+ return result;
}
async getDeletedSpacePages(
spaceId: string,
+ userId: string,
pagination: PaginationOptions,
): Promise> {
- return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
+ const result = await this.pageRepo.getDeletedPagesInSpace(
+ spaceId,
+ pagination,
+ );
+
+ if (result.items.length > 0) {
+ const pageIds = result.items.map((p) => p.id);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId,
+ spaceId,
+ });
+ const accessibleSet = new Set(accessibleIds);
+ result.items = result.items.filter((p) => accessibleSet.has(p.id));
+ }
+
+ return result;
}
async forceDelete(pageId: string, workspaceId: string): Promise {
@@ -776,4 +929,61 @@ export class PageService {
return prosemirrorJson;
}
+
+ /**
+ * Filters a list of pages to only those accessible to the user while maintaining tree integrity.
+ * A page is included only if:
+ * 1. The user has access to it
+ * 2. Its parent is also included (or it's the root page)
+ * This ensures that if a middle page is inaccessible, its entire subtree is excluded.
+ */
+ private async filterAccessibleTreePages<
+ T extends { id: string; parentPageId: string | null },
+ >(
+ pages: T[],
+ rootPageId: string,
+ userId: string,
+ spaceId?: string,
+ ): Promise {
+ if (pages.length === 0) return [];
+
+ const pageIds = pages.map((p) => p.id);
+ const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
+ {
+ pageIds,
+ userId,
+ spaceId,
+ },
+ );
+ const accessibleSet = new Set(accessibleIds);
+
+ // Prune: include a page only if it's accessible AND its parent chain to root is included
+ const includedIds = new Set();
+
+ // Process pages in a way that ensures parents are processed before children
+ // We do this by iterating until no more pages can be added
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const page of pages) {
+ if (includedIds.has(page.id)) continue;
+ if (!accessibleSet.has(page.id)) continue;
+
+ // Root page: include if accessible
+ if (page.id === rootPageId) {
+ includedIds.add(page.id);
+ changed = true;
+ continue;
+ }
+
+ // Non-root: include if parent is already included
+ if (page.parentPageId && includedIds.has(page.parentPageId)) {
+ includedIds.add(page.id);
+ changed = true;
+ }
+ }
+ }
+
+ return pages.filter((p) => includedIds.has(p.id));
+ }
}
diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts
index 53a1f27e..c5c94af7 100644
--- a/apps/server/src/core/search/search.service.ts
+++ b/apps/server/src/core/search/search.service.ts
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@@ -18,6 +19,7 @@ export class SearchService {
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
+ private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
@@ -115,10 +117,23 @@ export class SearchService {
}
//@ts-ignore
- queryResults = await queryResults.execute();
+ let results: any[] = await queryResults.execute();
+
+ // Filter results by page-level permissions (if user is authenticated)
+ if (opts.userId && results.length > 0) {
+ const pageIds = results.map((r: any) => r.id);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId: opts.userId,
+ spaceId: searchParams.spaceId,
+ });
+ const accessibleSet = new Set(accessibleIds);
+ results = results.filter((r: any) => accessibleSet.has(r.id));
+ }
//@ts-ignore
- const searchResults = queryResults.map((result: SearchResponseDto) => {
+ const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
@@ -207,6 +222,19 @@ export class SearchService {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
+
+ // Filter by page-level permissions
+ if (pages.length > 0) {
+ const pageIds = pages.map((p) => p.id);
+ const accessibleIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId,
+ spaceId: suggestion?.spaceId,
+ });
+ const accessibleSet = new Set(accessibleIds);
+ pages = pages.filter((p) => accessibleSet.has(p.id));
+ }
}
return { users, groups, pages };
diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts
index fb7639c1..6097f197 100644
--- a/apps/server/src/core/share/share.controller.ts
+++ b/apps/server/src/core/share/share.controller.ts
@@ -11,12 +11,7 @@ import {
} from '@nestjs/common';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
-import {
- SpaceCaslAction,
- SpaceCaslSubject,
-} from '../casl/interfaces/space-ability.type';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
-import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { ShareService } from './share.service';
import {
CreateShareDto,
@@ -26,6 +21,8 @@ import {
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+import { PageAccessService } from '../page/page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@@ -38,9 +35,10 @@ import { hasLicenseOrEE } from '../../common/helpers';
export class ShareController {
constructor(
private readonly shareService: ShareService,
- private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
+ private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService,
) {}
@@ -119,10 +117,7 @@ export class ShareController {
throw new NotFoundException('Shared page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanView(page, user);
return this.shareService.getShareForPage(page.id, workspace.id);
}
@@ -140,9 +135,17 @@ export class ShareController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
- throw new ForbiddenException();
+ // User must be able to edit the page to create a share
+ //TODO: i dont think this is neccessary if we prevent restricted pages from getting shared
+ // rather, use space level permission and workspace/space level sharing restriction
+ await this.pageAccessService.validateCanEdit(page, user);
+
+ // Prevent sharing restricted pages
+ const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
+ page.id,
+ );
+ if (isRestricted) {
+ throw new BadRequestException('Cannot share a restricted page');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
@@ -170,11 +173,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
- const ability = await this.spaceAbility.createForUser(user, share.spaceId);
- if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
- throw new ForbiddenException();
+ const page = await this.pageRepo.findById(share.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
}
+ // User must be able to edit the page to update its share
+ await this.pageAccessService.validateCanEdit(page, user);
+
return this.shareService.updateShare(share.id, updateShareDto);
}
@@ -187,11 +193,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
- const ability = await this.spaceAbility.createForUser(user, share.spaceId);
- if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
- throw new ForbiddenException();
+ const page = await this.pageRepo.findById(share.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
}
+ // User must be able to edit the page to delete its share
+ await this.pageAccessService.validateCanEdit(page, user);
+
await this.shareRepo.deleteShare(share.id);
}
diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts
index c34ebff9..9753fdbb 100644
--- a/apps/server/src/core/share/share.service.ts
+++ b/apps/server/src/core/share/share.service.ts
@@ -19,6 +19,7 @@ import {
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
@@ -31,6 +32,7 @@ export class ShareService {
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
@@ -41,12 +43,20 @@ export class ShareService {
throw new NotFoundException('Share not found');
}
- if (share.includeSubPages) {
- const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
- includeContent: false,
- });
+ const isRestricted =
+ await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
+ if (isRestricted) {
+ throw new NotFoundException('Share not found');
+ }
- return { share, pageTree: pageList };
+ if (share.includeSubPages) {
+ const pageTree =
+ await this.pageRepo.getPageAndDescendantsExcludingRestricted(
+ share.pageId,
+ { includeContent: false },
+ );
+
+ return { share, pageTree };
} else {
return { share, pageTree: [] };
}
@@ -112,6 +122,13 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
+ // Block access to restricted pages
+ const isRestricted =
+ await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
+ if (isRestricted) {
+ throw new NotFoundException('Shared page not found');
+ }
+
page.content = await this.updatePublicAttachments(page);
return { page, share };
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
index 6272ead1..765fee4f 100644
--- a/apps/server/src/database/database.module.ts
+++ b/apps/server/src/database/database.module.ts
@@ -15,6 +15,7 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageRepo } from './repos/page/page.repo';
+import { PagePermissionRepo } from './repos/page/page-permission.repo';
import { CommentRepo } from './repos/comment/comment.repo';
import { PageHistoryRepo } from './repos/page/page-history.repo';
import { AttachmentRepo } from './repos/attachment/attachment.repo';
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
+ PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -94,6 +96,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
+ PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
diff --git a/apps/server/src/database/migrations/20260224T233803-page-permissions.ts b/apps/server/src/database/migrations/20260224T233803-page-permissions.ts
new file mode 100644
index 00000000..25dffcad
--- /dev/null
+++ b/apps/server/src/database/migrations/20260224T233803-page-permissions.ts
@@ -0,0 +1,90 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('page_access')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('page_id', 'uuid', (col) =>
+ col.notNull().unique().references('pages.id').onDelete('cascade'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('space_id', 'uuid', (col) =>
+ col.notNull().references('spaces.id').onDelete('cascade'),
+ )
+ .addColumn('access_level', 'varchar', (col) => col.notNull())
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createTable('page_permissions')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('page_access_id', 'uuid', (col) =>
+ col.notNull().references('page_access.id').onDelete('cascade'),
+ )
+ .addColumn('user_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('cascade'),
+ )
+ .addColumn('group_id', 'uuid', (col) =>
+ col.references('groups.id').onDelete('cascade'),
+ )
+ .addColumn('role', 'varchar', (col) => col.notNull())
+ .addColumn('added_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addUniqueConstraint('page_access_user_unique', [
+ 'page_access_id',
+ 'user_id',
+ ])
+ .addUniqueConstraint('page_access_group_unique', [
+ 'page_access_id',
+ 'group_id',
+ ])
+ .addCheckConstraint(
+ 'allow_either_user_id_or_group_id_check',
+ sql`((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL))`,
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_page_access_space')
+ .on('page_access')
+ .column('space_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_page_permissions_user')
+ .on('page_permissions')
+ .column('user_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_page_permissions_group')
+ .on('page_permissions')
+ .column('group_id')
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('page_permissions').ifExists().execute();
+ await db.schema.dropTable('page_access').ifExists().execute();
+}
diff --git a/apps/server/src/database/pagination/cursor-pagination.ts b/apps/server/src/database/pagination/cursor-pagination.ts
index 8589a37a..4254702e 100644
--- a/apps/server/src/database/pagination/cursor-pagination.ts
+++ b/apps/server/src/database/pagination/cursor-pagination.ts
@@ -306,6 +306,21 @@ export function defaultEncodeCursor<
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
}
+export function emptyCursorPaginationResult(
+ limit: number,
+): CursorPaginationResult {
+ return {
+ items: [],
+ meta: {
+ limit,
+ hasNextPage: false,
+ hasPrevPage: false,
+ nextCursor: null,
+ prevCursor: null,
+ },
+ };
+}
+
export function defaultDecodeCursor<
DB,
TB extends keyof DB,
diff --git a/apps/server/src/database/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts
index da4528fb..08184e2c 100644
--- a/apps/server/src/database/repos/group/group-user.repo.ts
+++ b/apps/server/src/database/repos/group/group-user.repo.ts
@@ -175,4 +175,14 @@ export class GroupUserRepo {
.where('groupId', '=', groupId)
.execute();
}
+
+ async getUserGroupIds(userId: string): Promise {
+ const results = await this.db
+ .selectFrom('groupUsers')
+ .select('groupId')
+ .where('userId', '=', userId)
+ .execute();
+
+ return results.map((r) => r.groupId);
+ }
}
diff --git a/apps/server/src/database/repos/page/page-permission.repo.ts b/apps/server/src/database/repos/page/page-permission.repo.ts
new file mode 100644
index 00000000..fbd7423c
--- /dev/null
+++ b/apps/server/src/database/repos/page/page-permission.repo.ts
@@ -0,0 +1,1109 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
+import { dbOrTx } from '@docmost/db/utils';
+import {
+ InsertablePageAccess,
+ InsertablePagePermission,
+ PageAccess,
+ PagePermission,
+} from '@docmost/db/types/entity.types';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import { ExpressionBuilder, sql, SqlBool } from 'kysely';
+import { GroupRepo } from '@docmost/db/repos/group/group.repo';
+import { DB } from '@docmost/db/types/db';
+import {
+ CursorPaginationResult,
+ executeWithCursorPagination,
+} from '@docmost/db/pagination/cursor-pagination';
+import { PagePermissionMember } from './types/page-permission.types';
+
+export { PagePermissionMember } from './types/page-permission.types';
+
+@Injectable()
+export class PagePermissionRepo {
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ private readonly groupRepo: GroupRepo,
+ ) {}
+
+ async findPageAccessByPageId(
+ pageId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .selectFrom('pageAccess')
+ .selectAll()
+ .where('pageId', '=', pageId)
+ .executeTakeFirst();
+ }
+
+ async insertPageAccess(
+ data: InsertablePageAccess,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('pageAccess')
+ .values(data)
+ .returningAll()
+ .executeTakeFirst();
+ }
+
+ async deletePageAccess(
+ pageId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db.deleteFrom('pageAccess').where('pageId', '=', pageId).execute();
+ }
+
+ async insertPagePermissions(
+ permissions: InsertablePagePermission[],
+ trx?: KyselyTransaction,
+ ): Promise {
+ if (permissions.length === 0) return;
+ const db = dbOrTx(this.db, trx);
+ await db.insertInto('pagePermissions').values(permissions).execute();
+ }
+
+ async findPagePermissionByUserId(
+ pageAccessId: string,
+ userId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .selectFrom('pagePermissions')
+ .selectAll()
+ .where('pageAccessId', '=', pageAccessId)
+ .where('userId', '=', userId)
+ .executeTakeFirst();
+ }
+
+ async findPagePermissionByGroupId(
+ pageAccessId: string,
+ groupId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .selectFrom('pagePermissions')
+ .selectAll()
+ .where('pageAccessId', '=', pageAccessId)
+ .where('groupId', '=', groupId)
+ .executeTakeFirst();
+ }
+
+ async deletePagePermissionByUserId(
+ pageAccessId: string,
+ userId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('pagePermissions')
+ .where('pageAccessId', '=', pageAccessId)
+ .where('userId', '=', userId)
+ .execute();
+ }
+
+ async deletePagePermissionByGroupId(
+ pageAccessId: string,
+ groupId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('pagePermissions')
+ .where('pageAccessId', '=', pageAccessId)
+ .where('groupId', '=', groupId)
+ .execute();
+ }
+
+ async deletePagePermissionsByUserIds(
+ pageAccessId: string,
+ userIds: string[],
+ trx?: KyselyTransaction,
+ ): Promise {
+ if (userIds.length === 0) return;
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('pagePermissions')
+ .where('pageAccessId', '=', pageAccessId)
+ .where('userId', 'in', userIds)
+ .execute();
+ }
+
+ async deletePagePermissionsByGroupIds(
+ pageAccessId: string,
+ groupIds: string[],
+ trx?: KyselyTransaction,
+ ): Promise {
+ if (groupIds.length === 0) return;
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('pagePermissions')
+ .where('pageAccessId', '=', pageAccessId)
+ .where('groupId', 'in', groupIds)
+ .execute();
+ }
+
+ async updatePagePermissionRole(
+ pageAccessId: string,
+ role: string,
+ opts: { userId?: string; groupId?: string },
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ let query = db
+ .updateTable('pagePermissions')
+ .set({ role, updatedAt: new Date() })
+ .where('pageAccessId', '=', pageAccessId);
+
+ if (opts.userId) {
+ query = query.where('userId', '=', opts.userId);
+ } else if (opts.groupId) {
+ query = query.where('groupId', '=', opts.groupId);
+ }
+
+ await query.execute();
+ }
+
+ async countWritersByPageAccessId(
+ pageAccessId: string,
+ opts?: { trx?: KyselyTransaction },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+ const result = await db
+ .selectFrom('pagePermissions')
+ .select((eb) => eb.fn.count('id').as('count'))
+ .where('pageAccessId', '=', pageAccessId)
+ .where('role', '=', 'writer')
+ .executeTakeFirst();
+ return Number(result?.count ?? 0);
+ }
+
+ async getPagePermissionsPaginated(
+ pageAccessId: string,
+ pagination: PaginationOptions,
+ ): Promise> {
+ let baseQuery = this.db
+ .selectFrom('pagePermissions')
+ .leftJoin('users', 'users.id', 'pagePermissions.userId')
+ .leftJoin('groups', 'groups.id', 'pagePermissions.groupId')
+ .select([
+ 'pagePermissions.id',
+ 'pagePermissions.role',
+ 'pagePermissions.createdAt',
+ 'users.id as userId',
+ 'users.name as userName',
+ 'users.avatarUrl as userAvatarUrl',
+ 'users.email as userEmail',
+ 'groups.id as groupId',
+ 'groups.name as groupName',
+ 'groups.isDefault as groupIsDefault',
+ ])
+ .select((eb) => this.groupRepo.withMemberCount(eb))
+ .select((eb) =>
+ eb
+ .case()
+ .when('groups.id', 'is not', null)
+ .then(1)
+ .else(0)
+ .end()
+ .as('isGroup'),
+ )
+ .where('pageAccessId', '=', pageAccessId);
+
+ if (pagination.query) {
+ baseQuery = baseQuery.where((eb) =>
+ eb(
+ sql`f_unaccent(users.name)`,
+ 'ilike',
+ sql`f_unaccent(${'%' + pagination.query + '%'})`,
+ )
+ .or(
+ sql`users.email`,
+ 'ilike',
+ sql`f_unaccent(${'%' + pagination.query + '%'})`,
+ )
+ .or(
+ sql`f_unaccent(groups.name)`,
+ 'ilike',
+ sql`f_unaccent(${'%' + pagination.query + '%'})`,
+ ),
+ );
+ }
+
+ const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
+ const result = await executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
+ { expression: 'sub.id', direction: 'asc', key: 'id' },
+ ],
+ parseCursor: (cursor) => ({
+ isGroup: parseInt(cursor.isGroup, 10),
+ id: cursor.id,
+ }),
+ });
+
+ const items: PagePermissionMember[] = result.items.map((member) => {
+ if (member.userId) {
+ return {
+ id: member.userId,
+ name: member.userName,
+ email: member.userEmail,
+ avatarUrl: member.userAvatarUrl,
+ type: 'user' as const,
+ role: member.role,
+ createdAt: member.createdAt,
+ };
+ } else {
+ return {
+ id: member.groupId,
+ name: member.groupName,
+ memberCount: member.memberCount as number,
+ isDefault: member.groupIsDefault,
+ type: 'group' as const,
+ role: member.role,
+ createdAt: member.createdAt,
+ };
+ }
+ });
+
+ return { items, meta: result.meta };
+ }
+
+ async getUserPagePermission(
+ userId: string,
+ pageId: string,
+ ): Promise<{ role: string } | undefined> {
+ const result = await this.db
+ .selectFrom('pageAccess')
+ .innerJoin(
+ 'pagePermissions',
+ 'pagePermissions.pageAccessId',
+ 'pageAccess.id',
+ )
+ .select(['pagePermissions.role'])
+ .where('pageAccess.pageId', '=', pageId)
+ .where('pagePermissions.userId', '=', userId)
+ .unionAll(
+ this.db
+ .selectFrom('pageAccess')
+ .innerJoin(
+ 'pagePermissions',
+ 'pagePermissions.pageAccessId',
+ 'pageAccess.id',
+ )
+ .innerJoin(
+ 'groupUsers',
+ 'groupUsers.groupId',
+ 'pagePermissions.groupId',
+ )
+ .select(['pagePermissions.role'])
+ .where('pageAccess.pageId', '=', pageId)
+ .where('groupUsers.userId', '=', userId),
+ )
+ .executeTakeFirst();
+
+ return result;
+ }
+
+ async findRestrictedAncestor(pageId: string): Promise<
+ | {
+ pageAccessId: string;
+ pageId: string;
+ accessLevel: string;
+ depth: number;
+ }
+ | undefined
+ > {
+ return this.db
+ .withRecursive('ancestors', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select([
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ sql`0`.as('depth'),
+ ])
+ .where('pages.id', '=', pageId)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
+ .select([
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ sql`ancestors.depth + 1`.as('depth'),
+ ]),
+ ),
+ )
+ .selectFrom('ancestors')
+ .innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
+ .select([
+ 'pageAccess.id as pageAccessId',
+ 'pageAccess.pageId',
+ 'pageAccess.accessLevel',
+ 'ancestors.depth',
+ ])
+ .orderBy('ancestors.depth', 'asc')
+ .executeTakeFirst();
+ }
+
+ /**
+ * Check if user can access a page by verifying they have permission on ALL restricted ancestors.
+ */
+ async canUserAccessPage(userId: string, pageId: string): Promise {
+ const deniedAncestor = await this.db
+ .withRecursive('ancestors', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select(['pages.id as ancestorId', 'pages.parentPageId'])
+ .where('pages.id', '=', pageId)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
+ .select(['pages.id as ancestorId', 'pages.parentPageId']),
+ ),
+ )
+ .selectFrom('ancestors')
+ .innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
+ .leftJoin('pagePermissions', (join) =>
+ join
+ .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
+ .on((eb) =>
+ eb.or([
+ eb('pagePermissions.userId', '=', userId),
+ eb(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb, userId),
+ ),
+ ]),
+ ),
+ )
+ .select('pageAccess.pageId')
+ .where('pagePermissions.id', 'is', null)
+ .executeTakeFirst();
+
+ return !deniedAncestor;
+ }
+
+ /**
+ * Check if user can edit a page.
+ * Single query: builds ancestor chain once, checks both traversal and nearest-restricted writer.
+ * - bool_and(pp.id IS NOT NULL): false if any restricted ancestor has no permission (traversal denied)
+ * - array_agg(role ORDER BY depth)[1]: role on the nearest restricted ancestor
+ * - Zero rows (no restricted ancestors): both NULL → defer to space permissions (true)
+ */
+ async canUserEditPage(
+ userId: string,
+ pageId: string,
+ ): Promise<{
+ hasAnyRestriction: boolean;
+ canAccess: boolean;
+ canEdit: boolean;
+ }> {
+ const result = await sql<{
+ canAccess: boolean | null;
+ canEdit: boolean | null;
+ }>`
+ WITH RECURSIVE ancestors AS (
+ SELECT id AS ancestor_id, parent_page_id, 0 AS depth
+ FROM pages
+ WHERE id = ${pageId}::uuid
+ UNION ALL
+ SELECT p.id, p.parent_page_id, a.depth + 1
+ FROM pages p
+ JOIN ancestors a ON a.parent_page_id = p.id
+ )
+ SELECT
+ bool_and(pp.id IS NOT NULL) AS "canAccess",
+ -- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
+ (array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
+ FROM ancestors a
+ JOIN page_access pa ON pa.page_id = a.ancestor_id
+ LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
+ AND (
+ pp.user_id = ${userId}::uuid
+ OR pp.group_id IN (
+ SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
+ )
+ )
+ `.execute(this.db);
+
+ const row = result.rows[0];
+ if (!row || row.canAccess === null) {
+ return { hasAnyRestriction: false, canAccess: true, canEdit: true };
+ }
+ return {
+ hasAnyRestriction: true,
+ canAccess: row.canAccess,
+ canEdit: row.canAccess && (row.canEdit ?? false),
+ };
+ }
+
+ /**
+ * Get user's access level for a page.
+ * Returns:
+ * - hasDirectRestriction: whether this specific page has restrictions
+ * - hasInheritedRestriction: whether any ancestor (not self) has restrictions
+ * - hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction
+ * - canAccess: user has permission on all restricted ancestors (always true if no restrictions)
+ * - canEdit: user has writer on nearest restricted ancestor (always true if no restrictions)
+ */
+ async getUserPageAccessLevel(
+ userId: string,
+ pageId: string,
+ ): Promise<{
+ hasDirectRestriction: boolean;
+ hasInheritedRestriction: boolean;
+ hasAnyRestriction: boolean;
+ canAccess: boolean;
+ canEdit: boolean;
+ }> {
+ const result = await this.db
+ .withRecursive('ancestors', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select([
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ sql`0`.as('depth'),
+ ])
+ .where('pages.id', '=', pageId)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
+ .select([
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ sql`ancestors.depth + 1`.as('depth'),
+ ]),
+ ),
+ )
+ .selectFrom('pages')
+ .select((eb) => [
+ // hasDirectRestriction: this page itself has page_access entry
+ eb
+ .case()
+ .when(
+ eb.exists(
+ eb
+ .selectFrom('pageAccess')
+ .select('pageAccess.id')
+ .whereRef('pageAccess.pageId', '=', 'pages.id'),
+ ),
+ )
+ .then(true)
+ .else(false)
+ .end()
+ .as('hasDirectRestriction'),
+ // hasInheritedRestriction: any ancestor (depth > 0) has page_access entry
+ eb
+ .case()
+ .when(
+ eb.exists(
+ eb
+ .selectFrom('ancestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'ancestors.ancestorId',
+ )
+ .select('pageAccess.id')
+ .where('ancestors.depth', '>', 0),
+ ),
+ )
+ .then(true)
+ .else(false)
+ .end()
+ .as('hasInheritedRestriction'),
+ // canAccess: no restricted ancestor without ANY permission
+ eb
+ .case()
+ .when(
+ eb.not(
+ eb.exists(
+ eb
+ .selectFrom('ancestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'ancestors.ancestorId',
+ )
+ .leftJoin('pagePermissions', (join) =>
+ join
+ .onRef(
+ 'pagePermissions.pageAccessId',
+ '=',
+ 'pageAccess.id',
+ )
+ .on((eb2) =>
+ eb2.or([
+ eb2('pagePermissions.userId', '=', userId),
+ eb2(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb2, userId),
+ ),
+ ]),
+ ),
+ )
+ .select('pageAccess.pageId')
+ .where('pagePermissions.id', 'is', null),
+ ),
+ ),
+ )
+ .then(true)
+ .else(false)
+ .end()
+ .as('canAccess'),
+ // canEdit: nearest restricted ancestor determines edit capability
+ eb
+ .case()
+ // traversal denied: any restricted ancestor without any permission
+ .when(
+ eb.exists(
+ eb
+ .selectFrom('ancestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'ancestors.ancestorId',
+ )
+ .leftJoin('pagePermissions', (join) =>
+ join
+ .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
+ .on((eb2) =>
+ eb2.or([
+ eb2('pagePermissions.userId', '=', userId),
+ eb2(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb2, userId),
+ ),
+ ]),
+ ),
+ )
+ .select('pageAccess.pageId')
+ .where('pagePermissions.id', 'is', null),
+ ),
+ )
+ .then(false)
+ // no restricted ancestors at all → defer to space permissions
+ .when(
+ eb.not(
+ eb.exists(
+ eb
+ .selectFrom('ancestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'ancestors.ancestorId',
+ )
+ .select('pageAccess.id'),
+ ),
+ ),
+ )
+ .then(true)
+ // nearest restricted ancestor has writer for this user
+ .when(
+ eb.exists(
+ eb
+ .selectFrom('pagePermissions')
+ .select('pagePermissions.id')
+ .where('pagePermissions.role', '=', 'writer')
+ .where(
+ 'pagePermissions.pageAccessId',
+ '=',
+ sql`(
+ SELECT pa.id FROM ancestors a_nr
+ JOIN page_access pa ON pa.page_id = a_nr.ancestor_id
+ ORDER BY a_nr.depth ASC
+ LIMIT 1
+ )`,
+ )
+ .where((eb2) =>
+ eb2.or([
+ eb2('pagePermissions.userId', '=', userId),
+ eb2(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb2, userId),
+ ),
+ ]),
+ ),
+ ),
+ )
+ .then(true)
+ .else(false)
+ .end()
+ .as('canEdit'),
+ ])
+ .where('pages.id', '=', pageId)
+ .executeTakeFirst();
+
+ const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
+ const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
+
+ return {
+ hasDirectRestriction,
+ hasInheritedRestriction,
+ hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
+ canAccess: Boolean(result?.canAccess),
+ canEdit: Boolean(result?.canEdit),
+ };
+ }
+
+ /**
+ * Filter a list of page IDs to only those the user can access.
+ * Returns page IDs with their permission level (canEdit).
+ * Single query implementation for efficiency.
+ */
+ async filterAccessiblePageIds(opts: {
+ pageIds: string[];
+ userId: string;
+ spaceId?: string;
+ }): Promise {
+ const { pageIds, userId, spaceId } = opts;
+ if (pageIds.length === 0) return [];
+
+ if (spaceId) {
+ const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId);
+ if (!hasRestrictions) {
+ return pageIds;
+ }
+ }
+
+ const results = await this.db
+ .withRecursive('allAncestors', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select([
+ 'pages.id as pageId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ ])
+ .where(sql`pages.id = ANY(${pageIds}::uuid[])`)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin(
+ 'allAncestors',
+ 'allAncestors.parentPageId',
+ 'pages.id',
+ )
+ .select([
+ 'allAncestors.pageId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ ]),
+ ),
+ )
+ .selectFrom('pages')
+ .select('pages.id')
+ .where(sql`pages.id = ANY(${pageIds}::uuid[])`)
+ .where(({ not, exists, selectFrom }) =>
+ not(
+ exists(
+ selectFrom('allAncestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'allAncestors.ancestorId',
+ )
+ .leftJoin('pagePermissions', (join) =>
+ join
+ .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
+ .on((eb) =>
+ eb.or([
+ eb('pagePermissions.userId', '=', userId),
+ eb(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb, userId),
+ ),
+ ]),
+ ),
+ )
+ .select('pageAccess.pageId')
+ .whereRef('allAncestors.pageId', '=', 'pages.id')
+ .where('pagePermissions.id', 'is', null),
+ ),
+ ),
+ )
+ .execute();
+
+ return results.map((r) => r.id);
+ }
+
+ async filterAccessiblePageIdsWithPermissions(
+ pageIds: string[],
+ userId: string,
+ ): Promise> {
+ if (pageIds.length === 0) return [];
+
+ const results = await this.db
+ .withRecursive('allAncestors', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select([
+ 'pages.id as pageId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ sql`0`.as('depth'),
+ ])
+ .where(sql`pages.id = ANY(${pageIds}::uuid[])`)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin(
+ 'allAncestors',
+ 'allAncestors.parentPageId',
+ 'pages.id',
+ )
+ .select([
+ 'allAncestors.pageId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId',
+ sql`all_ancestors.depth + 1`.as('depth'),
+ ]),
+ ),
+ )
+ .selectFrom('pages')
+ .select('pages.id')
+ .select((eb) =>
+ eb
+ .case()
+ // no restricted ancestors for this page → defer to space
+ .when(
+ eb.not(
+ eb.exists(
+ eb
+ .selectFrom('allAncestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'allAncestors.ancestorId',
+ )
+ .select('pageAccess.id')
+ .whereRef('allAncestors.pageId', '=', 'pages.id'),
+ ),
+ ),
+ )
+ .then(true)
+ // nearest restricted ancestor has writer for this user
+ .when(
+ eb.exists(
+ eb
+ .selectFrom('pagePermissions')
+ .select('pagePermissions.id')
+ .where('pagePermissions.role', '=', 'writer')
+ .where(
+ 'pagePermissions.pageAccessId',
+ '=',
+ sql`(
+ SELECT pa.id FROM all_ancestors aa
+ JOIN page_access pa ON pa.page_id = aa.ancestor_id
+ WHERE aa.page_id = pages.id
+ ORDER BY aa.depth ASC
+ LIMIT 1
+ )`,
+ )
+ .where((eb2) =>
+ eb2.or([
+ eb2('pagePermissions.userId', '=', userId),
+ eb2(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb2, userId),
+ ),
+ ]),
+ ),
+ ),
+ )
+ .then(true)
+ .else(false)
+ .end()
+ .as('canEdit'),
+ )
+ .where(sql`pages.id = ANY(${pageIds}::uuid[])`)
+ // view filter: no restricted ancestor without any permission
+ .where(({ not, exists, selectFrom }) =>
+ not(
+ exists(
+ selectFrom('allAncestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'allAncestors.ancestorId',
+ )
+ .leftJoin('pagePermissions', (join) =>
+ join
+ .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
+ .on((eb) =>
+ eb.or([
+ eb('pagePermissions.userId', '=', userId),
+ eb(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb, userId),
+ ),
+ ]),
+ ),
+ )
+ .select('pageAccess.pageId')
+ .whereRef('allAncestors.pageId', '=', 'pages.id')
+ .where('pagePermissions.id', 'is', null),
+ ),
+ ),
+ )
+ .execute();
+
+ return results.map((r) => ({ id: r.id, canEdit: Boolean(r.canEdit) }));
+ }
+
+ /**
+ * Check if a page or any of its ancestors has restrictions.
+ * Used to determine if page-level permission checks are needed.
+ */
+ async hasRestrictedAncestor(pageId: string): Promise {
+ const result = await this.db
+ .withRecursive('ancestors', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select(['pages.id as ancestorId', 'pages.parentPageId'])
+ .where('pages.id', '=', pageId)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
+ .select(['pages.id as ancestorId', 'pages.parentPageId']),
+ ),
+ )
+ .selectFrom('ancestors')
+ .innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
+ .select('pageAccess.id')
+ .executeTakeFirst();
+
+ return !!result;
+ }
+
+ /**
+ * Check if any page in a space has restrictions.
+ * Used as a quick check to skip heavy permission filtering when no restrictions exist.
+ */
+ async hasRestrictedPagesInSpace(spaceId: string): Promise {
+ const result = await this.db
+ .selectNoFrom((eb) =>
+ eb
+ .exists(
+ eb
+ .selectFrom('pageAccess')
+ .select(sql`1`.as('one'))
+ .where('pageAccess.spaceId', '=', spaceId),
+ )
+ .as('exists'),
+ )
+ .executeTakeFirst();
+
+ return Boolean(result?.exists);
+ }
+
+ /**
+ * Given a list of parent page IDs, return which ones have at least one accessible child.
+ * Efficient batch query for sidebar hasChildren calculation.
+ */
+ async getParentIdsWithAccessibleChildren(
+ parentIds: string[],
+ userId: string,
+ ): Promise {
+ if (parentIds.length === 0) return [];
+
+ const results = await this.db
+ .withRecursive('childAncestors', (qb) =>
+ qb
+ .selectFrom('pages as child')
+ .select([
+ 'child.id as childId',
+ 'child.id as ancestorId',
+ 'child.parentPageId as ancestorParentId',
+ ])
+ .where('child.parentPageId', 'in', parentIds)
+ .where('child.deletedAt', 'is', null)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin(
+ 'childAncestors',
+ 'childAncestors.ancestorParentId',
+ 'pages.id',
+ )
+ .select([
+ 'childAncestors.childId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId as ancestorParentId',
+ ]),
+ ),
+ )
+ .selectFrom('pages as child')
+ .select('child.parentPageId')
+ .distinct()
+ .where('child.parentPageId', 'in', parentIds)
+ .where('child.deletedAt', 'is', null)
+ .where(({ not, exists, selectFrom }) =>
+ not(
+ exists(
+ selectFrom('childAncestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'childAncestors.ancestorId',
+ )
+ .leftJoin('pagePermissions', (join) =>
+ join
+ .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
+ .on((eb) =>
+ eb.or([
+ eb('pagePermissions.userId', '=', userId),
+ eb(
+ 'pagePermissions.groupId',
+ 'in',
+ this.userGroupIdsSubquery(eb, userId),
+ ),
+ ]),
+ ),
+ )
+ .select('pageAccess.pageId')
+ .whereRef('childAncestors.childId', '=', 'child.id')
+ .where('pagePermissions.id', 'is', null),
+ ),
+ ),
+ )
+ .execute();
+
+ return results.map((r) => r.parentPageId);
+ }
+
+ /**
+ * Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
+ * Used to filter pages from public shares - if a page is restricted, it and all its
+ * children should be hidden.
+ */
+ async getRestrictedSubtreeIds(rootPageId: string): Promise {
+ const results = await this.db
+ .withRecursive('descendants', (qb) =>
+ qb
+ .selectFrom('pages')
+ .select(['pages.id as descendantId', 'pages.parentPageId'])
+ .where('pages.id', '=', rootPageId)
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin(
+ 'descendants',
+ 'descendants.descendantId',
+ 'pages.parentPageId',
+ )
+ .select(['pages.id as descendantId', 'pages.parentPageId'])
+ .where('pages.deletedAt', 'is', null),
+ ),
+ )
+ .withRecursive('descendantAncestors', (qb) =>
+ qb
+ .selectFrom('descendants')
+ .innerJoin('pages', 'pages.id', 'descendants.descendantId')
+ .select([
+ 'descendants.descendantId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId as ancestorParentId',
+ ])
+ .unionAll((eb) =>
+ eb
+ .selectFrom('pages')
+ .innerJoin(
+ 'descendantAncestors',
+ 'descendantAncestors.ancestorParentId',
+ 'pages.id',
+ )
+ .select([
+ 'descendantAncestors.descendantId',
+ 'pages.id as ancestorId',
+ 'pages.parentPageId as ancestorParentId',
+ ]),
+ ),
+ )
+ .selectFrom('descendantAncestors')
+ .innerJoin(
+ 'pageAccess',
+ 'pageAccess.pageId',
+ 'descendantAncestors.ancestorId',
+ )
+ .select('descendantAncestors.descendantId')
+ .distinct()
+ .execute();
+
+ return results.map((r) => r.descendantId);
+ }
+
+ /**
+ * Given a pageId and a set of candidate userIds, return the subset who can
+ * access the page (have permission on ALL restricted ancestors).
+ * Returns all userIds if the page has no restricted ancestors.
+ */
+ async getUserIdsWithPageAccess(
+ pageId: string,
+ userIds: string[],
+ ): Promise {
+ if (userIds.length === 0) return [];
+
+ const results = await sql<{ userId: string }>`
+ WITH RECURSIVE ancestors AS (
+ SELECT id AS ancestor_id, parent_page_id
+ FROM pages
+ WHERE id = ${pageId}::uuid
+ UNION ALL
+ SELECT p.id, p.parent_page_id
+ FROM pages p
+ JOIN ancestors a ON a.parent_page_id = p.id
+ )
+ SELECT cu.user_id AS "userId"
+ FROM unnest(${userIds}::uuid[]) AS cu(user_id)
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM ancestors a
+ JOIN page_access pa ON pa.page_id = a.ancestor_id
+ LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
+ AND (
+ pp.user_id = cu.user_id
+ OR pp.group_id IN (
+ SELECT gu.group_id FROM group_users gu WHERE gu.user_id = cu.user_id
+ )
+ )
+ WHERE pp.id IS NULL
+ )
+ `.execute(this.db);
+
+ return results.rows.map((r) => r.userId);
+ }
+
+ private userGroupIdsSubquery(
+ eb: ExpressionBuilder,
+ userId: string,
+ ) {
+ return eb
+ .selectFrom('groupUsers')
+ .select('groupUsers.groupId')
+ .where('groupUsers.userId', '=', userId);
+ }
+}
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index b9ed90c7..14490312 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -175,11 +175,13 @@ export class PageRepo {
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
+ .where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
- .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
+ .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
+ .where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_descendants')
@@ -197,6 +199,7 @@ export class PageRepo {
deletedAt: currentDate,
})
.where('id', 'in', pageIds)
+ .where('deletedAt', 'is', null)
.execute();
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
@@ -472,4 +475,75 @@ export class PageRepo {
.selectAll()
.execute();
}
+
+ /**
+ * Get page and all descendants, excluding restricted pages and their subtrees.
+ * More efficient than getPageAndDescendants + filtering because:
+ * 1. Single DB query (no separate restricted IDs query)
+ * 2. Stops traversing at restricted pages (doesn't fetch data to discard)
+ * 3. No in-memory filtering needed
+ */
+ async getPageAndDescendantsExcludingRestricted(
+ parentPageId: string,
+ opts: { includeContent: boolean },
+ ) {
+ return (
+ this.db
+ .withRecursive('page_hierarchy', (db) =>
+ db
+ .selectFrom('pages')
+ .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
+ .select([
+ 'pages.id',
+ 'pages.slugId',
+ 'pages.title',
+ 'pages.icon',
+ 'pages.position',
+ 'pages.parentPageId',
+ 'pages.spaceId',
+ 'pages.workspaceId',
+ sql`page_access.id IS NOT NULL`.as('isRestricted'),
+ ])
+ .$if(opts?.includeContent, (qb) => qb.select('pages.content'))
+ .where('pages.id', '=', parentPageId)
+ .where('pages.deletedAt', 'is', null)
+ .unionAll((exp) =>
+ exp
+ .selectFrom('pages as p')
+ .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
+ .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
+ .select([
+ 'p.id',
+ 'p.slugId',
+ 'p.title',
+ 'p.icon',
+ 'p.position',
+ 'p.parentPageId',
+ 'p.spaceId',
+ 'p.workspaceId',
+ sql`page_access.id IS NOT NULL`.as('isRestricted'),
+ ])
+ .$if(opts?.includeContent, (qb) => qb.select('p.content'))
+ .where('p.deletedAt', 'is', null)
+ // Only recurse into children of non-restricted pages
+ .where('ph.isRestricted', '=', false),
+ ),
+ )
+ .selectFrom('page_hierarchy')
+ .select([
+ 'id',
+ 'slugId',
+ 'title',
+ 'icon',
+ 'position',
+ 'parentPageId',
+ 'spaceId',
+ 'workspaceId',
+ ])
+ .$if(opts?.includeContent, (qb) => qb.select('content'))
+ // Filter out restricted pages from the result
+ .where('isRestricted', '=', false)
+ .execute()
+ );
+ }
}
diff --git a/apps/server/src/database/repos/page/types/page-permission.types.ts b/apps/server/src/database/repos/page/types/page-permission.types.ts
new file mode 100644
index 00000000..5a7ff65f
--- /dev/null
+++ b/apps/server/src/database/repos/page/types/page-permission.types.ts
@@ -0,0 +1,23 @@
+type PagePermissionUserMember = {
+ id: string;
+ name: string;
+ email: string;
+ avatarUrl: string | null;
+ type: 'user';
+ role: string;
+ createdAt: Date;
+};
+
+type PagePermissionGroupMember = {
+ id: string;
+ name: string;
+ memberCount: number;
+ isDefault: boolean;
+ type: 'group';
+ role: string;
+ createdAt: Date;
+};
+
+export type PagePermissionMember =
+ | PagePermissionUserMember
+ | PagePermissionGroupMember;
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 6668398b..01c290a3 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -390,6 +390,28 @@ export interface Watchers {
createdAt: Generated;
}
+export interface PageAccess {
+ id: Generated;
+ pageId: string;
+ workspaceId: string;
+ spaceId: string;
+ accessLevel: string;
+ creatorId: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
+export interface PagePermissions {
+ id: Generated;
+ pageAccessId: string;
+ userId: string | null;
+ groupId: string | null;
+ role: string;
+ addedById: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -402,7 +424,10 @@ export interface DB {
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
+ pageAccess: PageAccess;
+ pageHierarchy: PageHierarchy;
pageHistory: PageHistory;
+ pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index 65e1024a..43e9a241 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -4,6 +4,8 @@ import {
Comments,
Groups,
Notifications,
+ PageAccess as _PageAccess,
+ PagePermissions as _PagePermissions,
Pages,
Spaces,
Users,
@@ -143,3 +145,13 @@ export type UpdatableNotification = Updateable>;
export type Watcher = Selectable;
export type InsertableWatcher = Insertable;
export type UpdatableWatcher = Updateable>;
+
+// Page Access
+export type PageAccess = Selectable<_PageAccess>;
+export type InsertablePageAccess = Insertable<_PageAccess>;
+export type UpdatablePageAccess = Updateable>;
+
+// Page Permission
+export type PagePermission = Selectable<_PagePermissions>;
+export type InsertablePagePermission = Insertable<_PagePermissions>;
+export type UpdatablePagePermission = Updateable>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 71b4323d..dc8da28f 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 71b4323d1b6ea3fbec061b0d31be33235d4ddbcd
+Subproject commit dc8da28f248cf56e1c11af1bfaed56d79848fb1b
diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts
index f5a5c11f..77e51b29 100644
--- a/apps/server/src/integrations/export/export.controller.ts
+++ b/apps/server/src/integrations/export/export.controller.ts
@@ -16,6 +16,7 @@ import { User } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
+import { PageAccessService } from '../../core/page/page-access/page-access.service';
import {
SpaceCaslAction,
SpaceCaslSubject,
@@ -32,6 +33,7 @@ export class ExportController {
private readonly exportService: ExportService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
+ private readonly pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -50,16 +52,14 @@ export class ExportController {
throw new NotFoundException('Page not found');
}
- const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
- throw new ForbiddenException();
- }
+ await this.pageAccessService.validateCanView(page, user);
const zipFileStream = await this.exportService.exportPages(
dto.pageId,
dto.format,
dto.includeAttachments,
dto.includeChildren,
+ user.id,
);
const fileName = sanitize(page.title || 'untitled') + '.zip';
@@ -82,7 +82,7 @@ export class ExportController {
@Res() res: FastifyReply,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
- if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
+ if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
@@ -90,6 +90,7 @@ export class ExportController {
dto.spaceId,
dto.format,
dto.includeAttachments,
+ user.id,
);
res.headers({
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index 655e31d3..57fcb681 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -25,6 +25,7 @@ import {
ExportPageMetadata,
} from '../../common/helpers/types/export-metadata.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -44,6 +45,7 @@ export class ExportService {
constructor(
private readonly pageRepo: PageRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
private readonly environmentService: EnvironmentService,
@@ -100,6 +102,8 @@ export class ExportService {
format: string,
includeAttachments: boolean,
includeChildren: boolean,
+ userId?: string,
+ ignorePermissions = false,
) {
let pages: Page[];
@@ -113,7 +117,7 @@ export class ExportService {
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
- if (page){
+ if (page) {
pages = [page];
}
}
@@ -122,14 +126,38 @@ export class ExportService {
throw new BadRequestException('No pages to export');
}
+ if (!ignorePermissions && userId) {
+ pages = await this.filterPagesForExport(
+ pages,
+ pageId,
+ userId,
+ pages[0].spaceId,
+ );
+ if (pages.length === 0) {
+ throw new BadRequestException('No accessible pages to export');
+ }
+ }
+
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
+
+ //After filtering by permissions, if the root page itself is not accessible to the user, findIndex returns -1
+ if (parentPageIndex === -1) {
+ throw new BadRequestException('Root page is not accessible');
+ }
// set to null to make export of pages with parentId work
pages[parentPageIndex].parentPageId = null;
const tree = buildTree(pages as Page[]);
const zip = new JSZip();
- await this.zipPages(tree, format, zip, includeAttachments);
+ await this.zipPages(
+ tree,
+ format,
+ zip,
+ includeAttachments,
+ userId,
+ ignorePermissions,
+ );
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
@@ -144,10 +172,12 @@ export class ExportService {
spaceId: string,
format: string,
includeAttachments: boolean,
+ userId?: string,
+ ignorePermissions = false,
) {
const space = await this.db
.selectFrom('spaces')
- .selectAll()
+ .select(['id', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst();
@@ -155,7 +185,7 @@ export class ExportService {
throw new NotFoundException('Space not found');
}
- const pages = await this.db
+ let pages = await this.db
.selectFrom('pages')
.select([
'pages.id',
@@ -174,11 +204,30 @@ export class ExportService {
.where('deletedAt', 'is', null)
.execute();
+ if (!ignorePermissions && userId) {
+ pages = await this.filterPagesForExport(
+ pages as Page[],
+ null,
+ userId,
+ spaceId,
+ );
+ if (pages.length === 0) {
+ throw new BadRequestException('No accessible pages to export');
+ }
+ }
+
const tree = buildTree(pages as Page[]);
const zip = new JSZip();
- await this.zipPages(tree, format, zip, includeAttachments);
+ await this.zipPages(
+ tree,
+ format,
+ zip,
+ includeAttachments,
+ userId,
+ ignorePermissions,
+ );
const zipFile = zip.generateNodeStream({
type: 'nodebuffer',
@@ -198,6 +247,8 @@ export class ExportService {
format: string,
zip: JSZip,
includeAttachments: boolean,
+ userId?: string,
+ ignorePermissions = false,
): Promise {
const slugIdToPath: Record = {};
const pageIdToFilePath: Record = {};
@@ -219,6 +270,8 @@ export class ExportService {
const prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
+ userId,
+ ignorePermissions,
);
const currentPagePath = slugIdToPath[page.slugId];
@@ -303,10 +356,15 @@ export class ExportService {
}
}
- async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
+ async turnPageMentionsToLinks(
+ prosemirrorJson: any,
+ workspaceId: string,
+ userId?: string,
+ ignorePermissions = false,
+ ) {
const doc = jsonToNode(prosemirrorJson);
- const pageMentionIds = [];
+ let pageMentionIds: string[] = [];
doc.descendants((node: Node) => {
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
@@ -320,13 +378,31 @@ export class ExportService {
return prosemirrorJson;
}
- const pages = await this.db
- .selectFrom('pages')
- .select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
- .select((eb) => this.pageRepo.withSpace(eb))
- .where('id', 'in', pageMentionIds)
- .where('workspaceId', '=', workspaceId)
- .execute();
+ // Filter to only accessible pages if permissions are enforced
+ if (!ignorePermissions && userId) {
+ pageMentionIds = await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds: pageMentionIds,
+ userId,
+ });
+ }
+
+ const pages =
+ pageMentionIds.length > 0
+ ? await this.db
+ .selectFrom('pages')
+ .select([
+ 'id',
+ 'slugId',
+ 'title',
+ 'creatorId',
+ 'spaceId',
+ 'workspaceId',
+ ])
+ .select((eb) => this.pageRepo.withSpace(eb))
+ .where('id', 'in', pageMentionIds)
+ .where('workspaceId', '=', workspaceId)
+ .execute()
+ : [];
const pageMap = new Map(pages.map((page) => [page.id, page]));
@@ -398,4 +474,52 @@ export class ExportService {
return updatedDoc.toJSON();
}
+
+ private async filterPagesForExport(
+ pages: Page[],
+ rootPageId: string | null,
+ userId: string,
+ spaceId: string,
+ ): Promise {
+ if (pages.length === 0) return [];
+
+ const pageIds = pages.map((p) => p.id);
+ const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
+ {
+ pageIds,
+ userId,
+ spaceId,
+ },
+ );
+ const accessibleSet = new Set(accessibleIds);
+
+ const includedIds = new Set();
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const page of pages) {
+ if (includedIds.has(page.id)) continue;
+ if (!accessibleSet.has(page.id)) continue;
+
+ // Root page or top-level page in space export
+ if (
+ page.id === rootPageId ||
+ (rootPageId === null && page.parentPageId === null)
+ ) {
+ includedIds.add(page.id);
+ changed = true;
+ continue;
+ }
+
+ // Non-root: include if parent is already included
+ if (page.parentPageId && includedIds.has(page.parentPageId)) {
+ includedIds.add(page.id);
+ changed = true;
+ }
+ }
+ }
+
+ return pages.filter((p) => includedIds.has(p.id));
+ }
}
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index a60fc184..c4d47947 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -67,4 +67,5 @@ export enum QueueJob {
COMMENT_NOTIFICATION = 'comment-notification',
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
+ PAGE_PERMISSION_GRANTED = 'page-permission-granted',
}
diff --git a/apps/server/src/integrations/queue/constants/queue.interface.ts b/apps/server/src/integrations/queue/constants/queue.interface.ts
index 613feb75..4254bbdc 100644
--- a/apps/server/src/integrations/queue/constants/queue.interface.ts
+++ b/apps/server/src/integrations/queue/constants/queue.interface.ts
@@ -1,5 +1,4 @@
-import { MentionNode } from "../../../common/helpers/prosemirror/utils";
-
+import { MentionNode } from '../../../common/helpers/prosemirror/utils';
export interface IPageBacklinkJob {
pageId: string;
@@ -60,3 +59,12 @@ export interface IPageMentionNotificationJob {
spaceId: string;
workspaceId: string;
}
+
+export interface IPermissionGrantedNotificationJob {
+ userIds: string[];
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ role: string;
+}
diff --git a/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx b/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx
new file mode 100644
index 00000000..f4aa878a
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx
@@ -0,0 +1,45 @@
+import { Section, Text, Button } from '@react-email/components';
+import * as React from 'react';
+import { button, content, paragraph } from '../css/styles';
+import { MailBody } from '../partials/partials';
+
+interface Props {
+ actorName: string;
+ pageTitle: string;
+ pageUrl: string;
+ accessLabel: string;
+}
+
+export const PermissionGrantedEmail = ({
+ actorName,
+ pageTitle,
+ pageUrl,
+ accessLabel,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ {actorName} gave you {accessLabel} access to{' '}
+ {pageTitle} .
+
+
+
+
+ );
+};
+
+export default PermissionGrantedEmail;
diff --git a/apps/server/src/ws/ws-tree.service.ts b/apps/server/src/ws/ws-tree.service.ts
new file mode 100644
index 00000000..8aadfa99
--- /dev/null
+++ b/apps/server/src/ws/ws-tree.service.ts
@@ -0,0 +1,47 @@
+import { Injectable } from '@nestjs/common';
+import { Page } from '@docmost/db/types/entity.types';
+import { WsService } from './ws.service';
+
+@Injectable()
+export class WsTreeService {
+ constructor(private readonly wsService: WsService) {}
+
+ async notifyPageRestricted(page: Page, excludeUserId: string): Promise {
+ await this.wsService.emitToSpaceExceptUsers(page.spaceId, [excludeUserId], {
+ operation: 'deleteTreeNode',
+ spaceId: page.spaceId,
+ payload: {
+ node: {
+ id: page.id,
+ slugId: page.slugId,
+ },
+ },
+ });
+ }
+
+ async notifyPermissionGranted(page: Page, userIds: string[]): Promise {
+ if (userIds.length === 0) return;
+
+ await this.wsService.emitToUsers(userIds, {
+ operation: 'addTreeNode',
+ spaceId: page.spaceId,
+ payload: {
+ parentId: page.parentPageId ?? null,
+ index: 0,
+ data: {
+ id: page.id,
+ slugId: page.slugId,
+ name: page.title ?? '',
+ title: page.title,
+ icon: page.icon,
+ position: page.position,
+ spaceId: page.spaceId,
+ parentPageId: page.parentPageId,
+ creatorId: page.creatorId,
+ hasChildren: false,
+ children: [],
+ },
+ },
+ });
+ }
+}
diff --git a/apps/server/src/ws/ws.gateway.ts b/apps/server/src/ws/ws.gateway.ts
index 9308f32e..231dfade 100644
--- a/apps/server/src/ws/ws.gateway.ts
+++ b/apps/server/src/ws/ws.gateway.ts
@@ -1,6 +1,7 @@
import {
MessageBody,
OnGatewayConnection,
+ OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
@@ -10,20 +11,30 @@ import { TokenService } from '../core/auth/services/token.service';
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
+import { WsService } from './ws.service';
+import { getSpaceRoomName, getUserRoomName } from './ws.utils';
import * as cookie from 'cookie';
@WebSocketGateway({
cors: { origin: '*' },
transports: ['websocket'],
})
-export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
+export class WsGateway
+ implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
+{
@WebSocketServer()
server: Server;
+
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
+ private wsService: WsService,
) {}
+ afterInit(server: Server): void {
+ this.wsService.setServer(server);
+ }
+
async handleConnection(client: Socket, ...args: any[]): Promise {
try {
const cookies = cookie.parse(client.handshake.headers.cookie);
@@ -35,11 +46,13 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const userId = token.sub;
const workspaceId = token.workspaceId;
+ client.data.userId = userId;
+
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
- const userRoom = `user-${userId}`;
+ const userRoom = getUserRoomName(userId);
const workspaceRoom = `workspace-${workspaceId}`;
- const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
+ const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
client.join([userRoom, workspaceRoom, ...spaceRooms]);
} catch (err) {
@@ -49,17 +62,9 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
}
@SubscribeMessage('message')
- handleMessage(client: Socket, data: any): void {
- const spaceEvents = [
- 'updateOne',
- 'addTreeNode',
- 'moveTreeNode',
- 'deleteTreeNode',
- ];
-
- if (spaceEvents.includes(data?.operation) && data?.spaceId) {
- const room = this.getSpaceRoomName(data.spaceId);
- client.broadcast.to(room).emit('message', data);
+ async handleMessage(client: Socket, data: any): Promise {
+ if (this.wsService.isTreeEvent(data)) {
+ await this.wsService.handleTreeEvent(client, data);
return;
}
@@ -82,8 +87,4 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
this.server.close();
}
}
-
- getSpaceRoomName(spaceId: string): string {
- return `space-${spaceId}`;
- }
}
diff --git a/apps/server/src/ws/ws.module.ts b/apps/server/src/ws/ws.module.ts
index d48cff94..d19e6076 100644
--- a/apps/server/src/ws/ws.module.ts
+++ b/apps/server/src/ws/ws.module.ts
@@ -1,10 +1,13 @@
-import { Module } from '@nestjs/common';
+import { Global, Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
+import { WsService } from './ws.service';
+import { WsTreeService } from './ws-tree.service';
import { TokenModule } from '../core/auth/token.module';
+@Global()
@Module({
imports: [TokenModule],
- providers: [WsGateway],
- exports: [WsGateway],
+ providers: [WsGateway, WsService, WsTreeService],
+ exports: [WsGateway, WsService, WsTreeService],
})
export class WsModule {}
diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts
new file mode 100644
index 00000000..e2bf4807
--- /dev/null
+++ b/apps/server/src/ws/ws.service.ts
@@ -0,0 +1,157 @@
+import { Inject, Injectable } from '@nestjs/common';
+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 {
+ TREE_EVENTS,
+ WS_SPACE_RESTRICTION_CACHE_PREFIX,
+ WS_CACHE_TTL_MS,
+ getSpaceRoomName,
+ getUserRoomName,
+} from './ws.utils';
+
+@Injectable()
+export class WsService {
+ private server: Server;
+
+ constructor(
+ private readonly pagePermissionRepo: PagePermissionRepo,
+ @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
+ ) {}
+
+ setServer(server: Server): void {
+ this.server = server;
+ }
+
+ async handleTreeEvent(client: Socket, data: any): Promise {
+ const room = getSpaceRoomName(data.spaceId);
+
+ const hasRestrictions = await this.spaceHasRestrictions(data.spaceId);
+ if (!hasRestrictions) {
+ client.broadcast.to(room).emit('message', data);
+ return;
+ }
+
+ const pageId = this.extractPageId(data);
+ if (!pageId) {
+ client.broadcast.to(room).emit('message', data);
+ return;
+ }
+
+ const isRestricted =
+ await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
+ if (!isRestricted) {
+ client.broadcast.to(room).emit('message', data);
+ return;
+ }
+
+ await this.broadcastToAuthorizedUsers(client, room, pageId, data);
+ }
+
+ async invalidateSpaceRestrictionCache(spaceId: string): Promise {
+ await this.cacheManager.del(
+ `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`,
+ );
+ }
+
+ async emitToUsers(userIds: string[], data: any): Promise {
+ if (userIds.length === 0) return;
+ const rooms = userIds.map((id) => getUserRoomName(id));
+ this.server.to(rooms).emit('message', data);
+ }
+
+ async emitToSpaceExceptUsers(
+ spaceId: string,
+ excludeUserIds: string[],
+ data: any,
+ ): Promise {
+ const room = getSpaceRoomName(spaceId);
+ const sockets = await this.server.in(room).fetchSockets();
+ const excludeSet = new Set(excludeUserIds);
+
+ for (const socket of sockets) {
+ const userId = socket.data.userId as string;
+ if (userId && !excludeSet.has(userId)) {
+ socket.emit('message', data);
+ }
+ }
+ }
+
+ isTreeEvent(data: any): boolean {
+ return TREE_EVENTS.has(data?.operation) && !!data?.spaceId;
+ }
+
+ private async broadcastToAuthorizedUsers(
+ sender: Socket,
+ room: string,
+ pageId: string,
+ data: any,
+ ): Promise {
+ const sockets = await this.server.in(room).fetchSockets();
+
+ const otherSockets = sockets.filter((s) => s.id !== sender.id);
+ if (otherSockets.length === 0) return;
+
+ const userSocketMap = new Map();
+ for (const socket of otherSockets) {
+ const userId = socket.data.userId as string;
+ if (!userId) continue;
+ const existing = userSocketMap.get(userId);
+ if (existing) {
+ existing.push(socket);
+ } else {
+ userSocketMap.set(userId, [socket]);
+ }
+ }
+
+ const candidateUserIds = Array.from(userSocketMap.keys());
+ if (candidateUserIds.length === 0) return;
+
+ const authorizedUserIds =
+ await this.pagePermissionRepo.getUserIdsWithPageAccess(
+ pageId,
+ candidateUserIds,
+ );
+
+ const authorizedSet = new Set(authorizedUserIds);
+ for (const [userId, userSockets] of userSocketMap) {
+ if (authorizedSet.has(userId)) {
+ for (const socket of userSockets) {
+ socket.emit('message', data);
+ }
+ }
+ }
+ }
+
+ private async spaceHasRestrictions(spaceId: string): Promise {
+ const cacheKey = `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
+
+ const cached = await this.cacheManager.get(cacheKey);
+ if (cached !== undefined && cached !== null) {
+ return cached;
+ }
+
+ const hasRestrictions =
+ await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
+
+ await this.cacheManager.set(cacheKey, hasRestrictions, WS_CACHE_TTL_MS);
+
+ return hasRestrictions;
+ }
+
+ private extractPageId(data: any): string | null {
+ switch (data.operation) {
+ case 'addTreeNode':
+ return data.payload?.data?.id ?? null;
+ case 'moveTreeNode':
+ return data.payload?.id ?? null;
+ case 'deleteTreeNode':
+ return data.payload?.node?.id ?? null;
+ case 'updateOne':
+ return data.id ?? null;
+ default:
+ return null;
+ }
+ }
+}
diff --git a/apps/server/src/ws/ws.utils.ts b/apps/server/src/ws/ws.utils.ts
new file mode 100644
index 00000000..0cf460f1
--- /dev/null
+++ b/apps/server/src/ws/ws.utils.ts
@@ -0,0 +1,17 @@
+export const WS_CACHE_TTL_MS = 30_000;
+export const WS_SPACE_RESTRICTION_CACHE_PREFIX = 'ws:space-restrictions:';
+
+export function getSpaceRoomName(spaceId: string): string {
+ return `space-${spaceId}`;
+}
+
+export function getUserRoomName(userId: string): string {
+ return `user-${userId}`;
+}
+
+export const TREE_EVENTS = new Set([
+ 'updateOne',
+ 'addTreeNode',
+ 'moveTreeNode',
+ 'deleteTreeNode',
+]);
diff --git a/apps/server/test/jest-e2e.json b/apps/server/test/jest-e2e.json
index e9d912f3..f8e05856 100644
--- a/apps/server/test/jest-e2e.json
+++ b/apps/server/test/jest-e2e.json
@@ -5,5 +5,10 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
+ },
+ "moduleNameMapper": {
+ "^@docmost/db/(.*)$": "/../src/database/$1",
+ "^@docmost/transactional/(.*)$": "/../src/integrations/transactional/$1",
+ "^@docmost/ee/(.*)$": "/../src/ee/$1"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73141a72..c1ef0abf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -477,6 +477,9 @@ importers:
'@fastify/static':
specifier: ^9.0.0
version: 9.0.0
+ '@keyv/redis':
+ specifier: ^5.1.6
+ version: 5.1.6(keyv@5.6.0)
'@langchain/core':
specifier: 1.1.18
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
@@ -489,6 +492,9 @@ importers:
'@nestjs/bullmq':
specifier: ^11.0.4
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.65.0)
+ '@nestjs/cache-manager':
+ specifier: ^3.1.0
+ version: 3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)
'@nestjs/common':
specifier: ^11.1.11
version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -2567,6 +2573,12 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@keyv/redis@5.1.6':
+ resolution: {integrity: sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ keyv: ^5.6.0
+
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
@@ -2770,6 +2782,15 @@ packages:
'@nestjs/core': ^10.0.0 || ^11.0.0
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
+ '@nestjs/cache-manager@3.1.0':
+ resolution: {integrity: sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==}
+ peerDependencies:
+ '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0
+ '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0
+ cache-manager: '>=6'
+ keyv: '>=5'
+ rxjs: ^7.8.1
+
'@nestjs/cli@11.0.16':
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
engines: {node: '>= 20.11'}
@@ -4029,6 +4050,15 @@ packages:
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
+ '@redis/client@5.11.0':
+ resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ '@node-rs/xxhash': ^1.1.0
+ peerDependenciesMeta:
+ '@node-rs/xxhash':
+ optional: true
+
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@@ -12870,6 +12900,15 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
+ '@keyv/redis@5.1.6(keyv@5.6.0)':
+ dependencies:
+ '@redis/client': 5.11.0
+ cluster-key-slot: 1.1.2
+ hookified: 1.15.1
+ keyv: 5.6.0
+ transitivePeerDependencies:
+ - '@node-rs/xxhash'
+
'@keyv/serialize@1.1.1': {}
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
@@ -13062,6 +13101,14 @@ snapshots:
bullmq: 5.65.0
tslib: 2.8.1
+ '@nestjs/cache-manager@3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)':
+ dependencies:
+ '@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.13(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ cache-manager: 7.2.8
+ keyv: 5.6.0
+ rxjs: 7.8.2
+
'@nestjs/cli@11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
@@ -14368,6 +14415,10 @@ snapshots:
dependencies:
react: 18.3.1
+ '@redis/client@5.11.0':
+ dependencies:
+ cluster-key-slot: 1.1.2
+
'@remirror/core-constants@3.0.0': {}
'@rolldown/pluginutils@1.0.0-beta.47': {}