mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -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 (
|
||||
|
||||
@@ -42,7 +42,6 @@ if (isCloud() && isPostHogEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 71b4323d1b...dc8da28f24
@@ -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;
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user