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
This commit is contained in:
Philip Okugbe
2026-02-26 19:49:10 +00:00
committed by GitHub
parent 22f33bab7c
commit 59e945562d
75 changed files with 4235 additions and 363 deletions
@@ -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."
}
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder
>
<Table.Caption>
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.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
@@ -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 (
<Menu withArrow disabled={disabled}>
<Menu.Target>
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
<div
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
>
<CurrentIcon size={18} stroke={1.5} />
</div>
<div style={{ flex: 1 }}>
<Group gap={4}>
<Text size="sm" fw={500}>
{currentLabel}
</Text>
{!disabled && <IconChevronDown size={14} />}
</Group>
<Text size="xs" c="dimmed">
{currentDescription}
</Text>
</div>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{accessOptions.map((option) => (
<Menu.Item
key={option.value}
onClick={() => onChange(option.value)}
leftSection={<option.icon size={16} stroke={1.5} />}
rightSection={
option.value === value ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</div>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
@@ -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 (
<div className={classes.permissionItem}>
<div className={classes.permissionItemInfo}>
{member.type === "user" && (
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
)}
{member.type === "group" && <IconGroupCircle />}
<div className={classes.permissionItemDetails}>
<AutoTooltipText
fz="sm"
fw={500}
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
>
{member.name}
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
<div className={classes.permissionItemRole}>
{isCurrentUser || disabled ? (
<Text size="sm" c="dimmed">
{t(roleLabel)}
</Text>
) : (
<Menu withArrow position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={4}>
<Text size="sm">{t(roleLabel)}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{pagePermissionRoleData.map((role) => (
<Menu.Item
key={role.value}
onClick={() => onRoleChange(member.id, member.type, role.value)}
rightSection={
role.value === member.role ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{t(role.label)}</Text>
<Text size="xs" c="dimmed">
{t(role.description)}
</Text>
</div>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
color="red"
onClick={() => onRemove(member.id, member.type)}
>
{t("Remove access")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
</div>
);
}
@@ -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<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(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: (
<Text size="sm">
{t(
"Are you sure you want to remove this member's access to the page?",
)}
</Text>
),
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: (
<Text size="sm">
{t(
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
)}
</Text>
),
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 (
<Center py="md">
<Loader size="sm" />
</Center>
);
}
if (members.length === 0) {
return null;
}
return (
<>
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
{t("People with access")}
</Text>
{canManage && members.length > 0 && (
<Text className={classes.removeAllLink} onClick={handleRemoveAll}>
{t("Remove all")}
</Text>
)}
</Group>
<ScrollArea mah={250} viewportRef={viewportRef}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
member={member}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
disabled={!canManage}
/>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</>
);
}
@@ -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<string[]>([]);
const [role, setRole] = useState<string>(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 (
<Stack gap="md">
{hasInheritedRestriction && (
<Paper className={classes.inheritedSection} p="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="lg"
radius="sm"
variant="light"
color="orange"
>
<IconShieldLock size={18} stroke={1.5} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Inherited restriction")}
</Text>
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("Access limited by")}
</Text>
{restrictionInfo.inheritedFrom && (
<Link
to={buildPageUrl(
spaceSlug,
restrictionInfo.inheritedFrom.slugId,
restrictionInfo.inheritedFrom.title,
)}
style={{ textDecoration: "none" }}
>
<Group gap={2}>
<Text size="xs" fw={500} c="blue">
{restrictionInfo.inheritedFrom.title || t("Untitled")}
</Text>
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
</Group>
</Link>
)}
</Group>
</Box>
</Group>
</Paper>
)}
<Box>
<GeneralAccessSelect
value={hasDirectRestriction ? "restricted" : "open"}
onChange={handleDirectAccessChange}
disabled={!canManage}
hasInheritedRestriction={hasInheritedRestriction}
/>
{!hasDirectRestriction && !hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Restrict access to control who can view and edit this page")}
</Text>
)}
{!hasDirectRestriction && hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Add additional restrictions specific to this page")}
</Text>
)}
</Box>
{hasDirectRestriction && (
<>
<Divider />
{canManage && (
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
</Box>
<Select
data={pagePermissionRoleData.map((r) => ({
label: t(r.label),
value: r.value,
}))}
value={role}
onChange={(value) => value && setRole(value)}
allowDeselect={false}
variant="filled"
w={120}
/>
<Button
onClick={handleAddMembers}
disabled={memberIds.length === 0}
loading={addPermissionMutation.isPending}
>
{t("Add")}
</Button>
</Group>
)}
<PagePermissionList
pageId={pageId}
canManage={canManage}
onRemoveAll={handleRemoveAll}
/>
</>
)}
</Stack>
);
}
@@ -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);
}
}
@@ -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<string | null>(
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 (
<>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
isRestricted ? (
<Indicator color="red" offset={5} withBorder>
<IconLock size={20} stroke={1.5} />
</Indicator>
) : isPubliclyShared ? (
<Indicator color="green" offset={5} withBorder>
<IconWorld size={20} stroke={1.5} />
</Indicator>
) : null
}
variant="default"
onClick={open}
>
{t("Share")}
</Button>
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
<Tabs.Tab
value="publish"
rightSection={
isPubliclyShared ? (
<Indicator color="green" size={8} processing />
) : null
}
>
{t("Publish")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="access">
{!isCloudEE ? (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Page permissions")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Control who can view and edit individual pages. Available with an enterprise license.",
)}
</Text>
</Stack>
) : restrictionLoading || !pageId || !restrictionInfo ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<PagePermissionTab
pageId={pageId}
restrictionInfo={restrictionInfo}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="publish">
<PublishTab
pageId={pageId}
readOnly={readOnly}
isRestricted={isRestricted}
workspaceSharingDisabled={workspaceSharingDisabled}
spaceSharingDisabled={spaceSharingDisabled}
/>
</Tabs.Panel>
</Tabs>
</Modal>
</>
);
}
@@ -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<boolean>(false);
useEffect(() => {
setIsPagePublic(!!share);
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
};
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
if (isCloud() && isTrial) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button size="xs" onClick={() => navigate("/settings/billing")}>
{t("Upgrade Plan")}
</Button>
</Stack>
);
}
if (workspaceSharingDisabled || spaceSharingDisabled) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceSharingDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</Stack>
);
}
if (isRestricted) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Restricted page")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("Restricted pages cannot be shared publicly.")}
</Text>
</Stack>
);
}
if (isDescendantShared) {
return (
<Stack gap="sm">
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={buildPageUrl(
spaceSlug,
share.sharedPage.slugId,
share.sharedPage.title,
)}
>
<Group gap="4" wrap="nowrap">
{getPageIcon(share.sharedPage.icon)}
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</Group>
</Anchor>
{shareLink}
</Stack>
);
}
return (
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">
{isPagePublic ? t("Shared to web") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
checked={isPagePublic}
disabled={readOnly}
size="xs"
/>
</Group>
{pageIsShared && (
<>
{shareLink}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
{t("Make sub-pages public too")}
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
disabled={readOnly}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>
)}
</Stack>
);
}
@@ -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 };
}
@@ -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";
@@ -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<IPageRestrictionInfo, Error> {
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<typeof useQueryClient>,
pageId: string,
hasRestriction: boolean,
) {
queryClient.setQueriesData<IPage>(
{ 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<void, Error, string>({
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<void, Error, string>({
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<void, Error, IAddPagePermission>({
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<void, Error, IRemovePagePermission>({
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<void, Error, IUpdatePagePermissionRole>({
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",
});
},
});
}
@@ -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<void> {
await api.post("/pages/restrict", { pageId });
}
export async function addPagePermission(
data: IAddPagePermission,
): Promise<void> {
await api.post("/pages/add-permission", data);
}
export async function removePagePermission(
data: IRemovePagePermission,
): Promise<void> {
await api.post("/pages/remove-permission", data);
}
export async function updatePagePermissionRole(
data: IUpdatePagePermissionRole,
): Promise<void> {
await api.post("/pages/update-permission", data);
}
export async function unrestrictPage(pageId: string): Promise<void> {
await api.post("/pages/remove-restriction", { pageId });
}
export async function getPagePermissions(
pageId: string,
cursor?: string,
): Promise<IPagination<IPagePermissionMember>> {
const req = await api.post<IPagination<IPagePermissionMember>>(
"/pages/permissions",
{ pageId, ...(cursor && { cursor }) },
);
return req.data;
}
export async function getPageRestrictionInfo(
pageId: string,
): Promise<IPageRestrictionInfo> {
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
pageId,
});
return req.data;
}
@@ -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;
}
@@ -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;
@@ -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() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role]
[comments, handleAddReply, isLoading, space?.membership?.role],
);
if (isCommentsLoading) {
@@ -199,7 +187,14 @@ function CommentListWithTabs() {
}
return (
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<div
style={{
height: "85vh",
display: "flex",
flexDirection: "column",
marginTop: "-15px",
}}
>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<Tabs.List justify="center">
<Tabs.Tab
@@ -273,9 +268,9 @@ const ChildComments = ({
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items]
[comments.items],
);
return (
@@ -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);
}
}
@@ -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 "";
}
@@ -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;
@@ -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 && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<PageShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
@@ -53,11 +53,7 @@ import {
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import {
useDisclosure,
useElementSize,
useMergedRef,
} from "@mantine/hooks";
import { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@@ -244,9 +240,19 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
{isRootReady && rootElement.current && (
<Tree
data={filteredData}
disableDrag={readOnly}
disableDrop={readOnly}
disableEdit={readOnly}
disableDrag={
readOnly
? true
: (data) => {
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<any>) {
<IconFileDescription size="18" />
)
}
readOnly={tree.props.disableEdit as boolean}
readOnly={
tree.props.disableEdit === true || node.data.canEdit === false
}
removeEmojiAction={handleRemoveEmoji}
/>
</div>
@@ -427,7 +435,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
{!tree.props.disableEdit && (
{tree.props.disableEdit !== true && node.data.canEdit !== false && (
<CreateNode
node={node}
treeApi={tree}
@@ -532,6 +540,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true,
children: [],
};
@@ -610,55 +619,56 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
{t("Export page")}
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
{treeApi.props.disableEdit !== true &&
node.data.canEdit !== false && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
@@ -7,5 +7,6 @@ export type SpaceTreeNode = {
spaceId: string;
parentPageId: string;
hasChildren: boolean;
canEdit?: boolean;
children: SpaceTreeNode[];
};
@@ -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: [],
};
});
@@ -18,10 +18,15 @@ export interface IPage {
deletedAt: Date;
position: string;
hasChildren: boolean;
canEdit?: boolean;
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial<ISpace>;
permissions?: {
canEdit: boolean;
hasRestriction: boolean;
};
}
interface ICreator {
@@ -69,19 +69,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>,
) => {
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<HTMLInputElement>,
) => {
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(
@@ -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",
});
},
});
}
@@ -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"] = ({
</Group>
);
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 (
<MultiSelect
data={data}
value={value}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
@@ -51,7 +51,7 @@ export default function SpaceSettingsModal({
</Modal.Header>
<Modal.Body>
<div style={{ height: rem(600) }}>
<Tabs defaultValue="members">
<Tabs color="dark" defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
{t("Settings")}
@@ -63,7 +63,7 @@ export default function SpaceSettingsModal({
<Tabs.Panel value="general">
<ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px"}}>
<div style={{ paddingBottom: "100px" }}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
@@ -72,7 +72,6 @@ export default function SpaceSettingsModal({
)}
/>
</div>
</ScrollArea>
</Tabs.Panel>
@@ -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(() => {
@@ -39,9 +39,13 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
-1
View File
@@ -42,7 +42,6 @@ if (isCloud() && isPostHogEnabled) {
});
}
const container = document.getElementById("root") as HTMLElement;
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
+3 -18
View File
@@ -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 }) {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<MemoizedPageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<MemoizedPageHeader readOnly={!canEdit} />
<MemoizedFullEditor
key={page.id}
@@ -115,10 +103,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
content={page.content}
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
editable={canEdit}
/>
<MemoizedHistoryModal pageId={page.id} />
</div>
+12
View File
@@ -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({
+8 -1
View File
@@ -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/(.*)$": "<rootDir>/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
}
}
}
+15
View File
@@ -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,
@@ -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';
@@ -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}`);
@@ -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
}
@@ -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) {
@@ -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(
+2
View File
@@ -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,
@@ -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 =
@@ -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,
@@ -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<void> {
@@ -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}`);
}
@@ -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,
@@ -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,
@@ -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 {}
@@ -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<void> {
// 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 };
}
}
+99 -39
View File
@@ -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);
}
}
@@ -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<CursorPaginationResult<Partial<Page> & { 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<string, CopyPageMapEntry>();
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<CursorPaginationResult<Page>> {
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<CursorPaginationResult<Page>> {
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<CursorPaginationResult<Page>> {
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<void> {
@@ -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<T[]> {
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<string>();
// 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));
}
}
+30 -2
View File
@@ -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 };
+28 -19
View File
@@ -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);
}
+22 -5
View File
@@ -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 };
@@ -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,
@@ -0,0 +1,90 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable('page_permissions').ifExists().execute();
await db.schema.dropTable('page_access').ifExists().execute();
}
@@ -306,6 +306,21 @@ export function defaultEncodeCursor<
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
}
export function emptyCursorPaginationResult<T>(
limit: number,
): CursorPaginationResult<T> {
return {
items: [],
meta: {
limit,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
};
}
export function defaultDecodeCursor<
DB,
TB extends keyof DB,
@@ -175,4 +175,14 @@ export class GroupUserRepo {
.where('groupId', '=', groupId)
.execute();
}
async getUserGroupIds(userId: string): Promise<string[]> {
const results = await this.db
.selectFrom('groupUsers')
.select('groupId')
.where('userId', '=', userId)
.execute();
return results.map((r) => r.groupId);
}
}
File diff suppressed because it is too large Load Diff
@@ -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<boolean>`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<boolean>`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()
);
}
}
@@ -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;
+25
View File
@@ -390,6 +390,28 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
workspaceId: string;
spaceId: string;
accessLevel: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
userId: string | null;
groupId: string | null;
role: string;
addedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
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;
@@ -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<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
@@ -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({
@@ -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<void> {
const slugIdToPath: Record<string, string> = {};
const pageIdToFilePath: Record<string, string> = {};
@@ -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<Page[]> {
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<string>();
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));
}
}
@@ -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',
}
@@ -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;
}
@@ -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 (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> gave you {accessLabel} access to{' '}
<strong>{pageTitle}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
export default PermissionGrantedEmail;
+47
View File
@@ -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<void> {
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<void> {
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: [],
},
},
});
}
}
+19 -18
View File
@@ -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<void> {
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<void> {
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}`;
}
}
+6 -3
View File
@@ -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 {}
+157
View File
@@ -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<void> {
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<void> {
await this.cacheManager.del(
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`,
);
}
async emitToUsers(userIds: string[], data: any): Promise<void> {
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<void> {
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<void> {
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<string, typeof otherSockets>();
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<boolean> {
const cacheKey = `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
const cached = await this.cacheManager.get<boolean>(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;
}
}
}
+17
View File
@@ -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',
]);
+5
View File
@@ -5,5 +5,10 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1"
}
}
+51
View File
@@ -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': {}