mirror of
https://github.com/docmost/docmost.git
synced 2026-05-13 02:34:05 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b1f2de978 | |||
| a34a6cba69 | |||
| 80b6ab13f3 | |||
| 9eeb28bb5b | |||
| b7abab9df4 | |||
| 03d38695ec | |||
| 56ef3e72d4 | |||
| e73f34d7e6 | |||
| 9fd33d1e15 | |||
| a2ec313878 | |||
| 9d154bf1f7 | |||
| 391642d01d | |||
| 7cfa8fd877 | |||
| 046d9a31f1 | |||
| dacc26dea7 | |||
| 7610e8458b | |||
| 648101860c | |||
| 66f09ae92d | |||
| 289eadb073 | |||
| 4f21fd7036 | |||
| f6e250958a | |||
| 0225764c2c | |||
| 8d06b2db6e | |||
| c630b5be38 | |||
| caf4d5a725 | |||
| 50fb8a1a52 | |||
| 51928de956 | |||
| bee6575c40 | |||
| dd3c75dcf5 | |||
| ff2a04b3ac | |||
| 6063b0ba3f | |||
| 884948da35 | |||
| 2a3ab9e11d | |||
| b4e8a5af9e | |||
| 1b13f80fb8 | |||
| 826bc0114d | |||
| a8900dce13 | |||
| c0e67e84a5 | |||
| 56780b4d42 | |||
| ff9743f2da | |||
| 495e7e62be | |||
| b65b53096a | |||
| 4b65d4d81d | |||
| e14e7db514 | |||
| a5696bb8e8 | |||
| 4c635b4faf | |||
| 56c1cfe7a9 | |||
| 8112c3578b | |||
| 3afc9b6e10 | |||
| 9a827b903a | |||
| 8863df4be4 | |||
| 50847be871 | |||
| 077d9723aa | |||
| f2de4a1839 | |||
| 873dd3bb51 | |||
| 8d9aa3b3aa | |||
| 29658b0572 | |||
| d17efaf26e | |||
| 8eb698648e | |||
| 0c3901abf5 | |||
| c2e722ee5c | |||
| f65726ae26 | |||
| 68a838606a | |||
| b0ceae39ba |
@@ -37,7 +37,6 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
|||||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
import Integrations from "@/features/integration/pages/integrations.tsx";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -103,7 +102,6 @@ export default function App() {
|
|||||||
<Route path={"sharing"} element={<Shares />} />
|
<Route path={"sharing"} element={<Shares />} />
|
||||||
<Route path={"security"} element={<Security />} />
|
<Route path={"security"} element={<Security />} />
|
||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
<Route path={"integrations"} element={<Integrations />} />
|
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
IconKey,
|
IconKey,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconPlug,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -117,12 +116,6 @@ const groupedData: DataGroup[] = [
|
|||||||
path: "/settings/ai",
|
path: "/settings/ai",
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Integrations",
|
|
||||||
icon: IconPlug,
|
|
||||||
path: "/settings/integrations",
|
|
||||||
isAdmin: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 specific people can access")
|
||||||
|
: 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("Everyone in this space can access"),
|
||||||
|
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,179 @@
|
|||||||
|
import { Avatar, Group, ScrollArea, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
|
||||||
|
import {
|
||||||
|
IPagePermissionMember,
|
||||||
|
PagePermissionRole,
|
||||||
|
} from "@/ee/page-permission/types/page-permission.types";
|
||||||
|
import {
|
||||||
|
useRemovePagePermissionMutation,
|
||||||
|
useUpdatePagePermissionRoleMutation,
|
||||||
|
} from "@/ee/page-permission/queries/page-permission-query";
|
||||||
|
import { PagePermissionItem } from "./page-permission-item";
|
||||||
|
import classes from "./page-permission.module.css";
|
||||||
|
|
||||||
|
type PagePermissionListProps = {
|
||||||
|
pageId: string;
|
||||||
|
members: IPagePermissionMember[];
|
||||||
|
canManage: boolean;
|
||||||
|
onRemoveAll?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PagePermissionList({
|
||||||
|
pageId,
|
||||||
|
members,
|
||||||
|
canManage,
|
||||||
|
onRemoveAll,
|
||||||
|
}: PagePermissionListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentUser = useAtomValue(userAtom);
|
||||||
|
const updateRoleMutation = useUpdatePagePermissionRoleMutation();
|
||||||
|
const removeMutation = useRemovePagePermissionMutation();
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSummaryText = () => {
|
||||||
|
const names: string[] = [];
|
||||||
|
let remaining = 0;
|
||||||
|
|
||||||
|
for (const member of sortedMembers) {
|
||||||
|
if (names.length < 2) {
|
||||||
|
if (member.type === "user" && member.id === currentUser?.id) {
|
||||||
|
names.push(t("You"));
|
||||||
|
} else {
|
||||||
|
names.push(member.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remaining++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `${names.join(", ")}, ${t("and {{count}} other", { count: remaining })}`;
|
||||||
|
}
|
||||||
|
return names.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.specificAccessHeader}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("Specific access")}
|
||||||
|
</Text>
|
||||||
|
{canManage && members.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={classes.removeAllLink}
|
||||||
|
onClick={handleRemoveAll}
|
||||||
|
>
|
||||||
|
{t("Remove all")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group gap={0} mb="xs">
|
||||||
|
<div className={classes.avatarStack}>
|
||||||
|
{sortedMembers.slice(0, 3).map((member, index) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={classes.avatarStackItem}
|
||||||
|
style={{ zIndex: sortedMembers.length - index }}
|
||||||
|
>
|
||||||
|
{member.type === "user" ? (
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={member.avatarUrl}
|
||||||
|
name={member.name}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar size={28} radius="xl">
|
||||||
|
<IconGroupCircle />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Text size="sm" ml="xs">
|
||||||
|
{getSummaryText()}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea mah={250}>
|
||||||
|
{sortedMembers.map((member) => (
|
||||||
|
<PagePermissionItem
|
||||||
|
key={`${member.type}-${member.id}`}
|
||||||
|
member={member}
|
||||||
|
onRoleChange={handleRoleChange}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
disabled={!canManage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
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,
|
||||||
|
usePagePermissionsQuery,
|
||||||
|
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 { data: permissionsData, isLoading } = usePagePermissionsQuery(pageId);
|
||||||
|
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>
|
||||||
|
<Link
|
||||||
|
to={buildPageUrl(
|
||||||
|
spaceSlug,
|
||||||
|
restrictionInfo.id,
|
||||||
|
restrictionInfo.title,
|
||||||
|
)}
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
<Group gap={2}>
|
||||||
|
<Text size="xs" fw={500} c="blue">
|
||||||
|
{restrictionInfo.title || t("Untitled")}
|
||||||
|
</Text>
|
||||||
|
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
{t("This page")}
|
||||||
|
</Text>
|
||||||
|
<GeneralAccessSelect
|
||||||
|
value={hasDirectRestriction ? "restricted" : "open"}
|
||||||
|
onChange={handleDirectAccessChange}
|
||||||
|
disabled={!canManage}
|
||||||
|
hasInheritedRestriction={hasInheritedRestriction}
|
||||||
|
/>
|
||||||
|
{!hasDirectRestriction && !hasInheritedRestriction && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t("Everyone in this space can access 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<PagePermissionList
|
||||||
|
pageId={pageId}
|
||||||
|
members={permissionsData?.items || []}
|
||||||
|
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,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Indicator,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Tabs,
|
||||||
|
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";
|
||||||
|
|
||||||
|
type PageShareModalProps = {
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageShareModal({ readOnly }: PageShareModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const pageSlugId = extractPageSlugId(pageSlug);
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>("share");
|
||||||
|
|
||||||
|
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 ? pageId : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
style={{ border: "none" }}
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={
|
||||||
|
<Indicator
|
||||||
|
color={isRestricted ? "red" : "green"}
|
||||||
|
offset={5}
|
||||||
|
disabled={!isRestricted && !isPubliclyShared}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
{isRestricted ? (
|
||||||
|
<IconLock size={20} stroke={1.5} />
|
||||||
|
) : (
|
||||||
|
<IconWorld size={20} stroke={1.5} />
|
||||||
|
)}
|
||||||
|
</Indicator>
|
||||||
|
}
|
||||||
|
variant="default"
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
{t("Share")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={t("Share")}
|
||||||
|
size={600}
|
||||||
|
>
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="share">{t("Share")}</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="publish">{t("Publish")}</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="share">
|
||||||
|
{restrictionLoading || !pageId ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<PagePermissionTab
|
||||||
|
pageId={pageId}
|
||||||
|
restrictionInfo={restrictionInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="publish">
|
||||||
|
<PublishTab pageId={pageId} readOnly={readOnly} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PublishTab({ pageId, readOnly }: 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 (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,159 @@
|
|||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
IAddPagePermission,
|
||||||
|
IPagePermissionMember,
|
||||||
|
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 { notifications } from "@mantine/notifications";
|
||||||
|
import { IPagination, QueryParams } from "@/lib/types";
|
||||||
|
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,
|
||||||
|
params?: QueryParams,
|
||||||
|
): UseQueryResult<IPagination<IPagePermissionMember>, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-permissions", pageId, params],
|
||||||
|
queryFn: () => getPagePermissions(pageId, params),
|
||||||
|
enabled: !!pageId,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestrictPageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (pageId) => restrictPage(pageId),
|
||||||
|
onSuccess: (_, pageId) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-restriction-info", pageId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-permissions", pageId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-restriction-info", pageId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-permissions", pageId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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, QueryParams } 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,
|
||||||
|
params?: QueryParams,
|
||||||
|
): Promise<IPagination<IPagePermissionMember>> {
|
||||||
|
const req = await api.post<IPagination<IPagePermissionMember>>(
|
||||||
|
"/pages/permissions",
|
||||||
|
{ pageId, ...params },
|
||||||
|
);
|
||||||
|
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,57 @@
|
|||||||
|
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 = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
hasDirectRestriction: boolean;
|
||||||
|
hasInheritedRestriction: boolean;
|
||||||
|
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 { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
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() {
|
function CommentListWithTabs() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -38,14 +33,7 @@ function CommentListWithTabs() {
|
|||||||
const isCloudEE = useIsCloudEE();
|
const isCloudEE = useIsCloudEE();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const canComment = page?.permissions?.canEdit ?? false;
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
|
|
||||||
const canComment: boolean = spaceAbility.can(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
const { activeComments, resolvedComments } = useMemo(() => {
|
||||||
@@ -54,14 +42,14 @@ function CommentListWithTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parentComments = comments.items.filter(
|
const parentComments = comments.items.filter(
|
||||||
(comment: IComment) => comment.parentCommentId === null
|
(comment: IComment) => comment.parentCommentId === null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const active = parentComments.filter(
|
const active = parentComments.filter(
|
||||||
(comment: IComment) => !comment.resolvedAt
|
(comment: IComment) => !comment.resolvedAt,
|
||||||
);
|
);
|
||||||
const resolved = parentComments.filter(
|
const resolved = parentComments.filter(
|
||||||
(comment: IComment) => comment.resolvedAt
|
(comment: IComment) => comment.resolvedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
@@ -89,7 +77,7 @@ function CommentListWithTabs() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id]
|
[createCommentMutation, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -131,7 +119,7 @@ function CommentListWithTabs() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
[comments, handleAddReply, isLoading, space?.membership?.role],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
@@ -199,7 +187,14 @@ function CommentListWithTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
||||||
<Tabs.List justify="center">
|
<Tabs.List justify="center">
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
@@ -273,9 +268,9 @@ const ChildComments = ({
|
|||||||
const getChildComments = useCallback(
|
const getChildComments = useCallback(
|
||||||
(parentId: string) =>
|
(parentId: string) =>
|
||||||
comments.items.filter(
|
comments.items.filter(
|
||||||
(comment: IComment) => comment.parentCommentId === parentId
|
(comment: IComment) => comment.parentCommentId === parentId,
|
||||||
),
|
),
|
||||||
[comments.items]
|
[comments.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
|||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
|
<Tooltip label={t("Add link")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
|||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { matchIntegrationLink } from "@docmost/editor-ext";
|
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
@@ -14,21 +13,6 @@ export const handlePaste = (
|
|||||||
) => {
|
) => {
|
||||||
const clipboardData = event.clipboardData.getData("text/plain");
|
const clipboardData = event.clipboardData.getData("text/plain");
|
||||||
|
|
||||||
const integrationMatch = matchIntegrationLink(clipboardData.trim());
|
|
||||||
if (integrationMatch && editor.state.selection.empty) {
|
|
||||||
event.preventDefault();
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setIntegrationLink({
|
|
||||||
url: clipboardData.trim(),
|
|
||||||
provider: integrationMatch.provider,
|
|
||||||
status: "pending",
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
||||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
.card {
|
|
||||||
max-width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 150ms ease;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
border-color: var(--mantine-color-blue-4);
|
|
||||||
}
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
import { NodeViewWrapper } from "@tiptap/react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
Badge,
|
|
||||||
Avatar,
|
|
||||||
Skeleton,
|
|
||||||
Anchor,
|
|
||||||
Stack,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEffect, useCallback, memo } from "react";
|
|
||||||
import { unfurlUrl } from "@/features/integration/services/integration-service";
|
|
||||||
import classes from "./integration-link-view.module.css";
|
|
||||||
|
|
||||||
const providerIcons: Record<string, string> = {
|
|
||||||
github: "https://github.githubassets.com/favicons/favicon-dark.svg",
|
|
||||||
gitlab: "https://gitlab.com/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8571da3c2571592f63571e0c5571.png",
|
|
||||||
jira: "https://wac-cdn.atlassian.com/assets/img/favicons/atlassian/favicon.png",
|
|
||||||
linear: "https://linear.app/favicon.ico",
|
|
||||||
google_docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
|
|
||||||
figma: "https://static.figma.com/app/icon/1/favicon.png",
|
|
||||||
};
|
|
||||||
|
|
||||||
function IntegrationLinkView(props: any) {
|
|
||||||
const { node, updateAttributes, editor } = props;
|
|
||||||
const { url, provider, unfurlData, status } = node.attrs;
|
|
||||||
|
|
||||||
const doUnfurl = useCallback(async () => {
|
|
||||||
if (status !== "pending" || !url) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await unfurlUrl({ url });
|
|
||||||
if (result) {
|
|
||||||
updateAttributes({
|
|
||||||
unfurlData: result,
|
|
||||||
status: "loaded",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateAttributes({ status: "error" });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
updateAttributes({ status: "error" });
|
|
||||||
}
|
|
||||||
}, [url, status, updateAttributes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "pending") {
|
|
||||||
doUnfurl();
|
|
||||||
}
|
|
||||||
}, [status, doUnfurl]);
|
|
||||||
|
|
||||||
if (status === "pending") {
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper data-drag-handle="">
|
|
||||||
<Card className={classes.card} withBorder padding="sm" radius="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Skeleton circle height={24} />
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Skeleton height={14} width="60%" />
|
|
||||||
<Skeleton height={10} width="80%" />
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "error" || !unfurlData) {
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper data-drag-handle="">
|
|
||||||
<Card className={classes.card} withBorder padding="sm" radius="sm">
|
|
||||||
<Anchor href={url} target="_blank" rel="noopener" size="sm">
|
|
||||||
{url}
|
|
||||||
</Anchor>
|
|
||||||
</Card>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconUrl = providerIcons[provider] ?? undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper data-drag-handle="">
|
|
||||||
<Card
|
|
||||||
className={classes.card}
|
|
||||||
withBorder
|
|
||||||
padding="sm"
|
|
||||||
radius="sm"
|
|
||||||
component="a"
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
style={{ textDecoration: "none", color: "inherit" }}
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
{unfurlData.authorAvatarUrl ? (
|
|
||||||
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" />
|
|
||||||
) : iconUrl ? (
|
|
||||||
<Avatar src={iconUrl} size={28} radius="sm" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text size="sm" fw={600} truncate>
|
|
||||||
{unfurlData.title}
|
|
||||||
</Text>
|
|
||||||
{unfurlData.status && (
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={unfurlData.statusColor ?? "gray"}
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{unfurlData.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{unfurlData.description && (
|
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
||||||
{unfurlData.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
{iconUrl && (
|
|
||||||
<Avatar src={iconUrl} size={14} radius="sm" />
|
|
||||||
)}
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{unfurlData.provider}
|
|
||||||
</Text>
|
|
||||||
{unfurlData.author && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
· {unfurlData.author}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(IntegrationLinkView);
|
|
||||||
@@ -27,7 +27,7 @@ export const LinkPreviewPanel = ({
|
|||||||
<>
|
<>
|
||||||
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<Tooltip label={url} withArrow withinPortal={false}>
|
<Tooltip label={url}>
|
||||||
<Anchor
|
<Anchor
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
IntegrationLink,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -61,7 +60,6 @@ import DrawioView from "../components/drawio/drawio-view";
|
|||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
|
|
||||||
import { common, createLowlight } from "lowlight";
|
import { common, createLowlight } from "lowlight";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
@@ -233,9 +231,6 @@ export const mainExtensions = [
|
|||||||
Subpages.configure({
|
Subpages.configure({
|
||||||
view: SubpagesView,
|
view: SubpagesView,
|
||||||
}),
|
}),
|
||||||
IntegrationLink.configure({
|
|
||||||
view: IntegrationLinkView,
|
|
||||||
}),
|
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -171,11 +171,14 @@ export function TitleEditor({
|
|||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// honor user default page edit mode preference
|
if (titleEditor) {
|
||||||
if (userPageEditMode && titleEditor && editable) {
|
if (userPageEditMode && editable) {
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
if (userPageEditMode === PageEditMode.Edit) {
|
||||||
titleEditor.setEditable(true);
|
titleEditor.setEditable(true);
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
} else if (userPageEditMode === PageEditMode.Read) {
|
||||||
|
titleEditor.setEditable(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
titleEditor.setEditable(false);
|
titleEditor.setEditable(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrandGithub,
|
|
||||||
IconBrandSlack,
|
|
||||||
IconBrandGitlab,
|
|
||||||
IconPuzzle,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
IntegrationDefinition,
|
|
||||||
Integration,
|
|
||||||
} from "../types/integration.types";
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = {
|
|
||||||
github: IconBrandGithub,
|
|
||||||
slack: IconBrandSlack,
|
|
||||||
gitlab: IconBrandGitlab,
|
|
||||||
};
|
|
||||||
|
|
||||||
type IntegrationCardProps = {
|
|
||||||
definition: IntegrationDefinition;
|
|
||||||
installation?: Integration;
|
|
||||||
onInstall: (type: string) => void;
|
|
||||||
onUninstall: (integrationId: string) => void;
|
|
||||||
onConfigure: (integration: Integration) => void;
|
|
||||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function IntegrationCard({
|
|
||||||
definition,
|
|
||||||
installation,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
onConfigure,
|
|
||||||
onToggle,
|
|
||||||
}: IntegrationCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const Icon = iconMap[definition.icon] ?? IconPuzzle;
|
|
||||||
const isInstalled = !!installation;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder padding="lg" radius="md">
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Icon size={28} stroke={1.5} />
|
|
||||||
<div>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{definition.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{definition.description}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xs" mb="md">
|
|
||||||
{definition.capabilities.map((cap) => (
|
|
||||||
<Badge key={cap} size="xs" variant="light">
|
|
||||||
{cap}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{isInstalled ? (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Switch
|
|
||||||
label={t("Enabled")}
|
|
||||||
checked={installation.isEnabled}
|
|
||||||
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => onConfigure(installation)}
|
|
||||||
>
|
|
||||||
{t("Configure")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={() => onUninstall(installation.id)}
|
|
||||||
>
|
|
||||||
{t("Uninstall")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => onInstall(definition.type)}
|
|
||||||
>
|
|
||||||
{t("Install")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Integration, ConnectionStatus } from "../types/integration.types";
|
|
||||||
import {
|
|
||||||
useConnectionStatus,
|
|
||||||
useDisconnectIntegration,
|
|
||||||
} from "../queries/integration-query";
|
|
||||||
import * as integrationService from "../services/integration-service";
|
|
||||||
|
|
||||||
type IntegrationSettingsModalProps = {
|
|
||||||
integration: Integration | null;
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function IntegrationSettingsModal({
|
|
||||||
integration,
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
}: IntegrationSettingsModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: connectionStatus } = useConnectionStatus(integration?.id);
|
|
||||||
const disconnectMutation = useDisconnectIntegration();
|
|
||||||
|
|
||||||
if (!integration) return null;
|
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
try {
|
|
||||||
const result = await integrationService.getOAuthAuthorizeUrl({
|
|
||||||
integrationId: integration.id,
|
|
||||||
});
|
|
||||||
window.location.href = result.authorizationUrl;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to start OAuth connection"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
await disconnectMutation.mutateAsync({
|
|
||||||
integrationId: integration.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasOAuth = true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
{hasOAuth && (
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={600} mb="xs">
|
|
||||||
{t("Connection")}
|
|
||||||
</Text>
|
|
||||||
{connectionStatus?.connected ? (
|
|
||||||
<Group gap="sm">
|
|
||||||
<Text size="sm" c="green">
|
|
||||||
{t("Connected")}
|
|
||||||
{connectionStatus.providerUserId &&
|
|
||||||
` (${connectionStatus.providerUserId})`}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
loading={disconnectMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Disconnect")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Button size="xs" variant="light" onClick={handleConnect}>
|
|
||||||
{t("Connect")} {integration.type}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useState, useCallback } from "react";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import IntegrationCard from "../components/integration-card";
|
|
||||||
import IntegrationSettingsModal from "../components/integration-settings-modal";
|
|
||||||
import {
|
|
||||||
useAvailableIntegrations,
|
|
||||||
useInstalledIntegrations,
|
|
||||||
useInstallIntegration,
|
|
||||||
useUninstallIntegration,
|
|
||||||
useUpdateIntegrationSettings,
|
|
||||||
} from "../queries/integration-query";
|
|
||||||
import { Integration } from "../types/integration.types";
|
|
||||||
|
|
||||||
export default function Integrations() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: available, isLoading: loadingAvailable } =
|
|
||||||
useAvailableIntegrations();
|
|
||||||
const { data: installed, isLoading: loadingInstalled } =
|
|
||||||
useInstalledIntegrations();
|
|
||||||
const installMutation = useInstallIntegration();
|
|
||||||
const uninstallMutation = useUninstallIntegration();
|
|
||||||
const updateMutation = useUpdateIntegrationSettings();
|
|
||||||
|
|
||||||
const [configuring, setConfiguring] = useState<Integration | null>(null);
|
|
||||||
|
|
||||||
const handleInstall = useCallback(
|
|
||||||
(type: string) => {
|
|
||||||
installMutation.mutate({ type });
|
|
||||||
},
|
|
||||||
[installMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUninstall = useCallback(
|
|
||||||
(integrationId: string) => {
|
|
||||||
uninstallMutation.mutate({ integrationId });
|
|
||||||
},
|
|
||||||
[uninstallMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfigure = useCallback((integration: Integration) => {
|
|
||||||
setConfiguring(integration);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(integration: Integration, enabled: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
integrationId: integration.id,
|
|
||||||
isEnabled: enabled,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = loadingAvailable || loadingInstalled;
|
|
||||||
const error = new URLSearchParams(window.location.search).get("error");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Integrations")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("Integrations")} />
|
|
||||||
|
|
||||||
{error === "oauth_failed" && (
|
|
||||||
<Alert color="red" mb="md">
|
|
||||||
{t("OAuth connection failed. Please try again.")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
) : !available?.length ? (
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t("No integrations available.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
{available.map((def) => {
|
|
||||||
const installation = installed?.find((i) => i.type === def.type);
|
|
||||||
return (
|
|
||||||
<IntegrationCard
|
|
||||||
key={def.type}
|
|
||||||
definition={def}
|
|
||||||
installation={installation}
|
|
||||||
onInstall={handleInstall}
|
|
||||||
onUninstall={handleUninstall}
|
|
||||||
onConfigure={handleConfigure}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IntegrationSettingsModal
|
|
||||||
integration={configuring}
|
|
||||||
opened={!!configuring}
|
|
||||||
onClose={() => setConfiguring(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import * as integrationService from "../services/integration-service";
|
|
||||||
|
|
||||||
export function useAvailableIntegrations() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["available-integrations"],
|
|
||||||
queryFn: integrationService.getAvailableIntegrations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInstalledIntegrations() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["installed-integrations"],
|
|
||||||
queryFn: integrationService.getInstalledIntegrations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInstallIntegration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.installIntegration,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Integration installed successfully") });
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to install integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUninstallIntegration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.uninstallIntegration,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Integration uninstalled successfully"),
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to uninstall integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateIntegrationSettings() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.updateIntegrationSettings,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Integration updated successfully") });
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to update integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConnectionStatus(integrationId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["integration-connection", integrationId],
|
|
||||||
queryFn: () =>
|
|
||||||
integrationService.getConnectionStatus({
|
|
||||||
integrationId: integrationId!,
|
|
||||||
}),
|
|
||||||
enabled: !!integrationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDisconnectIntegration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.disconnectIntegration,
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
notifications.show({ message: t("Integration disconnected") });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ["integration-connection", variables.integrationId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to disconnect integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
IntegrationDefinition,
|
|
||||||
Integration,
|
|
||||||
ConnectionStatus,
|
|
||||||
UnfurlResult,
|
|
||||||
} from "../types/integration.types";
|
|
||||||
|
|
||||||
export async function getAvailableIntegrations(): Promise<
|
|
||||||
IntegrationDefinition[]
|
|
||||||
> {
|
|
||||||
const req = await api.post<IntegrationDefinition[]>(
|
|
||||||
"/integrations/available",
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInstalledIntegrations(): Promise<Integration[]> {
|
|
||||||
const req = await api.post<Integration[]>("/integrations/list");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installIntegration(data: {
|
|
||||||
type: string;
|
|
||||||
}): Promise<Integration> {
|
|
||||||
const req = await api.post<Integration>("/integrations/install", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uninstallIntegration(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await api.post("/integrations/uninstall", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateIntegrationSettings(data: {
|
|
||||||
integrationId: string;
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
isEnabled?: boolean;
|
|
||||||
}): Promise<Integration> {
|
|
||||||
const req = await api.post<Integration>("/integrations/update", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConnectionStatus(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<ConnectionStatus> {
|
|
||||||
const req = await api.post<ConnectionStatus>(
|
|
||||||
"/integrations/connection/status",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOAuthAuthorizeUrl(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<{ authorizationUrl: string }> {
|
|
||||||
const req = await api.post<{ authorizationUrl: string }>(
|
|
||||||
"/integrations/oauth/authorize",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectIntegration(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await api.post("/integrations/oauth/disconnect", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unfurlUrl(data: {
|
|
||||||
url: string;
|
|
||||||
}): Promise<UnfurlResult | null> {
|
|
||||||
const req = await api.post<{ data: UnfurlResult | null }>(
|
|
||||||
"/integrations/unfurl",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data.data;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
|
|
||||||
|
|
||||||
export type IntegrationDefinition = {
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
capabilities: IntegrationCapability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Integration = {
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
type: string;
|
|
||||||
isEnabled: boolean;
|
|
||||||
settings: Record<string, any> | null;
|
|
||||||
installedById: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConnectionStatus = {
|
|
||||||
connected: boolean;
|
|
||||||
providerUserId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlResult = {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
url: string;
|
|
||||||
provider: string;
|
|
||||||
providerIcon?: string;
|
|
||||||
status?: string;
|
|
||||||
statusColor?: string;
|
|
||||||
author?: string;
|
|
||||||
authorAvatarUrl?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
};
|
|
||||||
@@ -40,6 +40,7 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
|||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||||
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -75,7 +76,9 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
<ShareModal readOnly={readOnly} />
|
{/*<ShareModal readOnly={readOnly} />*/}
|
||||||
|
<PageShareModal readOnly={readOnly}/>
|
||||||
|
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface IPage {
|
|||||||
lastUpdatedBy: ILastUpdatedBy;
|
lastUpdatedBy: ILastUpdatedBy;
|
||||||
deletedBy: IDeletedBy;
|
deletedBy: IDeletedBy;
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
|
permissions?: {
|
||||||
|
canEdit: boolean;
|
||||||
|
hasRestriction: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICreator {
|
interface ICreator {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface MultiMemberSelectProps {
|
interface MultiMemberSelectProps {
|
||||||
|
value?: string[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
@@ -85,6 +86,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
return (
|
return (
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
data={data}
|
data={data}
|
||||||
|
value={value}
|
||||||
renderOption={renderMultiSelectOption}
|
renderOption={renderMultiSelectOption}
|
||||||
hidePickedOptions
|
hidePickedOptions
|
||||||
maxDropdownHeight={300}
|
maxDropdownHeight={300}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const APP_ROUTE = {
|
|||||||
SPACES: "/settings/spaces",
|
SPACES: "/settings/spaces",
|
||||||
BILLING: "/settings/billing",
|
BILLING: "/settings/billing",
|
||||||
SECURITY: "/settings/security",
|
SECURITY: "/settings/security",
|
||||||
INTEGRATIONS: "/settings/integrations",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,9 +42,8 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
document.getElementById("root") as HTMLElement,
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
);
|
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import { Helmet } from "react-helmet-async";
|
|||||||
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
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 { useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
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 { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
const MemoizedFullEditor = React.memo(FullEditor);
|
const MemoizedFullEditor = React.memo(FullEditor);
|
||||||
const MemoizedPageHeader = React.memo(PageHeader);
|
const MemoizedPageHeader = React.memo(PageHeader);
|
||||||
const MemoizedHistoryModal = React.memo(HistoryModal);
|
const MemoizedHistoryModal = React.memo(HistoryModal);
|
||||||
@@ -58,8 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -101,12 +94,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<MemoizedPageHeader
|
<MemoizedPageHeader readOnly={!canEdit} />
|
||||||
readOnly={spaceAbility.cannot(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MemoizedFullEditor
|
<MemoizedFullEditor
|
||||||
key={page.id}
|
key={page.id}
|
||||||
@@ -115,10 +103,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
content={page.content}
|
content={page.content}
|
||||||
slugId={page.slugId}
|
slugId={page.slugId}
|
||||||
spaceSlug={page?.space?.slug}
|
spaceSlug={page?.space?.slug}
|
||||||
editable={spaceAbility.can(
|
editable={canEdit}
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<MemoizedHistoryModal pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,10 +39,12 @@
|
|||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@keyv/redis": "^5.1.6",
|
||||||
"@langchain/core": "1.1.18",
|
"@langchain/core": "1.1.18",
|
||||||
"@langchain/textsplitters": "1.0.1",
|
"@langchain/textsplitters": "1.0.1",
|
||||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
"@nestjs/common": "^11.1.11",
|
"@nestjs/common": "^11.1.11",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.13",
|
"@nestjs/core": "^11.1.13",
|
||||||
@@ -156,6 +158,11 @@
|
|||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"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 { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||||
import { CoreModule } from './core/core.module';
|
import { CoreModule } from './core/core.module';
|
||||||
import { EnvironmentModule } from './integrations/environment/environment.module';
|
import { EnvironmentModule } from './integrations/environment/environment.module';
|
||||||
import { CollaborationModule } from './collaboration/collaboration.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 { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
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';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
@@ -43,6 +46,18 @@ try {
|
|||||||
RedisModule.forRootAsync({
|
RedisModule.forRootAsync({
|
||||||
useClass: RedisConfigService,
|
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,
|
CollaborationModule,
|
||||||
WsModule,
|
WsModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TokenService } from '../../core/auth/services/token.service';
|
|||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.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 { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||||
import { getPageId } from '../collaboration.util';
|
import { getPageId } from '../collaboration.util';
|
||||||
@@ -23,6 +24,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onAuthenticate(data: onAuthenticatePayload) {
|
async onAuthenticate(data: onAuthenticatePayload) {
|
||||||
@@ -52,7 +54,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
|
|
||||||
const page = await this.pageRepo.findById(pageId);
|
const page = await this.pageRepo.findById(pageId);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
this.logger.warn(`Page not found: ${pageId}`);
|
this.logger.debug(`Page not found: ${pageId}`);
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +70,34 @@ export class AuthenticationExtension implements Extension {
|
|||||||
throw new UnauthorizedException();
|
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;
|
data.connectionConfig.readOnly = true;
|
||||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Authenticated user ${user.id} on 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.
|
OPEN = 'open', // any workspace member can see that it exists and join.
|
||||||
PRIVATE = 'private', // only added space users can see
|
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 { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { RemoveIconDto } from './dto/attachment.dto';
|
import { RemoveIconDto } from './dto/attachment.dto';
|
||||||
|
import { PageAccessService } from '../page-access/page-access.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@@ -67,6 +68,7 @@ export class AttachmentController {
|
|||||||
private readonly attachmentRepo: AttachmentRepo,
|
private readonly attachmentRepo: AttachmentRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly pageAccessService: PageAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -111,13 +113,7 @@ export class AttachmentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceAbility = await this.spaceAbility.createForUser(
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
user,
|
|
||||||
page.spaceId,
|
|
||||||
);
|
|
||||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const spaceId = page.spaceId;
|
const spaceId = page.spaceId;
|
||||||
|
|
||||||
@@ -172,15 +168,13 @@ export class AttachmentController {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceAbility = await this.spaceAbility.createForUser(
|
const page = await this.pageRepo.findById(attachment.pageId);
|
||||||
user,
|
if (!page) {
|
||||||
attachment.spaceId,
|
throw new NotFoundException();
|
||||||
);
|
|
||||||
|
|
||||||
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.sendFileResponse(req, res, attachment, 'private');
|
return await this.sendFileResponse(req, res, attachment, 'private');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
|
import { PageAccessService } from '../page-access/page-access.service';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('comments')
|
@Controller('comments')
|
||||||
@@ -33,6 +34,7 @@ export class CommentController {
|
|||||||
private readonly commentRepo: CommentRepo,
|
private readonly commentRepo: CommentRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly pageAccessService: PageAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -47,10 +49,7 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.commentService.create(
|
return this.commentService.create(
|
||||||
{
|
{
|
||||||
@@ -75,10 +74,8 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
return this.commentService.findByPageId(page.id, pagination);
|
return this.commentService.findByPageId(page.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +87,13 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
user,
|
if (!page) {
|
||||||
comment.spaceId,
|
throw new NotFoundException('Page not found');
|
||||||
);
|
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,18 +105,13 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
user,
|
if (!page) {
|
||||||
comment.spaceId,
|
throw new NotFoundException('Page not found');
|
||||||
);
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
return this.commentService.update(comment, dto, user);
|
return this.commentService.update(comment, dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,41 +123,27 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
user,
|
if (!page) {
|
||||||
comment.spaceId,
|
throw new NotFoundException('Page not found');
|
||||||
);
|
|
||||||
|
|
||||||
// must be a space member with edit permission
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check page-level edit permission first
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
// Check if user is the comment owner
|
// Check if user is the comment owner
|
||||||
const isOwner = comment.creatorId === user.id;
|
const isOwner = comment.creatorId === user.id;
|
||||||
|
|
||||||
if (isOwner) {
|
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);
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
comment.spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
// Space admin can delete any comment
|
// Space admin can delete any comment
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { SearchModule } from './search/search.module';
|
|||||||
import { SpaceModule } from './space/space.module';
|
import { SpaceModule } from './space/space.module';
|
||||||
import { GroupModule } from './group/group.module';
|
import { GroupModule } from './group/group.module';
|
||||||
import { CaslModule } from './casl/casl.module';
|
import { CaslModule } from './casl/casl.module';
|
||||||
|
import { PageAccessModule } from './page-access/page-access.module';
|
||||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
import { IntegrationModule } from './integration/integration.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,10 +32,10 @@ import { IntegrationModule } from './integration/integration.module';
|
|||||||
SpaceModule,
|
SpaceModule,
|
||||||
GroupModule,
|
GroupModule,
|
||||||
CaslModule,
|
CaslModule,
|
||||||
|
PageAccessModule,
|
||||||
ShareModule,
|
ShareModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
IntegrationModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
@@ -47,7 +47,6 @@ export class CoreModule implements NestModule {
|
|||||||
{ path: 'health', method: RequestMethod.GET },
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
{ path: 'health/live', method: RequestMethod.GET },
|
{ path: 'health/live', method: RequestMethod.GET },
|
||||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||||
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
|
|
||||||
)
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export enum IntegrationType {
|
|
||||||
SLACK = 'slack',
|
|
||||||
GITHUB = 'github',
|
|
||||||
GITLAB = 'gitlab',
|
|
||||||
JIRA = 'jira',
|
|
||||||
LINEAR = 'linear',
|
|
||||||
GOOGLE_DOCS = 'google_docs',
|
|
||||||
FIGMA = 'figma',
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
function deriveEncryptionKey(appSecret: string): Buffer {
|
|
||||||
return crypto.createHash('sha256').update(appSecret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encryptToken(token: string, appSecret: string): string {
|
|
||||||
const algorithm = 'aes-256-gcm';
|
|
||||||
const key = deriveEncryptionKey(appSecret);
|
|
||||||
const iv = crypto.randomBytes(16);
|
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
|
||||||
let encrypted = cipher.update(token, 'utf8', 'hex');
|
|
||||||
encrypted += cipher.final('hex');
|
|
||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptToken(encryptedToken: string, appSecret: string): string {
|
|
||||||
const algorithm = 'aes-256-gcm';
|
|
||||||
const key = deriveEncryptionKey(appSecret);
|
|
||||||
|
|
||||||
const parts = encryptedToken.split(':');
|
|
||||||
const iv = Buffer.from(parts[0], 'hex');
|
|
||||||
const authTag = Buffer.from(parts[1], 'hex');
|
|
||||||
const encrypted = parts[2];
|
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
|
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
||||||
decrypted += decipher.final('utf8');
|
|
||||||
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const slackSettingsSchema = z.object({
|
|
||||||
channelId: z.string().min(1),
|
|
||||||
channelName: z.string().optional(),
|
|
||||||
notifyOn: z
|
|
||||||
.array(z.enum(['page.created', 'page.updated', 'page.deleted']))
|
|
||||||
.default(['page.created']),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const githubSettingsSchema = z.object({
|
|
||||||
baseUrl: z.string().url().optional(),
|
|
||||||
org: z.string().optional(),
|
|
||||||
defaultRepo: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const gitlabSettingsSchema = z.object({
|
|
||||||
baseUrl: z.string().url().optional(),
|
|
||||||
group: z.string().optional(),
|
|
||||||
defaultProject: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const jiraSettingsSchema = z.object({
|
|
||||||
baseUrl: z.string().url().optional(),
|
|
||||||
cloudId: z.string().optional(),
|
|
||||||
siteName: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const linearSettingsSchema = z.object({
|
|
||||||
teamId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const integrationSettingsSchemas: Record<string, z.ZodType> = {
|
|
||||||
slack: slackSettingsSchema,
|
|
||||||
github: githubSettingsSchema,
|
|
||||||
gitlab: gitlabSettingsSchema,
|
|
||||||
jira: jiraSettingsSchema,
|
|
||||||
linear: linearSettingsSchema,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateIntegrationSettings(
|
|
||||||
type: string,
|
|
||||||
settings: unknown,
|
|
||||||
): { success: true; data: Record<string, any> } | { success: false; error: string } {
|
|
||||||
const schema = integrationSettingsSchemas[type];
|
|
||||||
if (!schema) {
|
|
||||||
if (settings && typeof settings === 'object') {
|
|
||||||
return { success: true, data: settings as Record<string, any> };
|
|
||||||
}
|
|
||||||
return { success: true, data: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = schema.safeParse(settings);
|
|
||||||
if (!result.success) {
|
|
||||||
const messages = result.error.issues.map(
|
|
||||||
(i) => `${i.path.join('.')}: ${i.message}`,
|
|
||||||
);
|
|
||||||
return { success: false, error: messages.join(', ') };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: result.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SlackSettings = z.infer<typeof slackSettingsSchema>;
|
|
||||||
export type GithubSettings = z.infer<typeof githubSettingsSchema>;
|
|
||||||
export type GitlabSettings = z.infer<typeof gitlabSettingsSchema>;
|
|
||||||
export type JiraSettings = z.infer<typeof jiraSettingsSchema>;
|
|
||||||
export type LinearSettings = z.infer<typeof linearSettingsSchema>;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class InstallIntegrationDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UninstallIntegrationDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateIntegrationDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IntegrationIdDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnfurlDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthAuthorizeDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthDisconnectDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationConnectionService {
|
|
||||||
constructor(
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getConnectionStatus(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<{ connected: boolean; providerUserId?: string }> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await this.connectionRepo.findByIntegrationAndUser(
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
connected: !!connection,
|
|
||||||
providerUserId: connection?.providerUserId ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegrationAndUser(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
return this.connectionRepo.findByIntegrationAndUser(integrationId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByWorkspaceTypeAndUser(
|
|
||||||
workspaceId: string,
|
|
||||||
integrationType: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
return this.connectionRepo.findByWorkspaceTypeAndUser(
|
|
||||||
workspaceId,
|
|
||||||
integrationType,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.connectionRepo.deleteByIntegrationAndUser(
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { IntegrationService } from './integration.service';
|
|
||||||
import { IntegrationConnectionService } from './integration-connection.service';
|
|
||||||
import {
|
|
||||||
InstallIntegrationDto,
|
|
||||||
UninstallIntegrationDto,
|
|
||||||
UpdateIntegrationDto,
|
|
||||||
IntegrationIdDto,
|
|
||||||
} from './dto/integration.dto';
|
|
||||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
|
||||||
import {
|
|
||||||
WorkspaceCaslAction,
|
|
||||||
WorkspaceCaslSubject,
|
|
||||||
} from '../casl/interfaces/workspace-ability.type';
|
|
||||||
|
|
||||||
@Controller('integrations')
|
|
||||||
export class IntegrationController {
|
|
||||||
constructor(
|
|
||||||
private readonly integrationService: IntegrationService,
|
|
||||||
private readonly connectionService: IntegrationConnectionService,
|
|
||||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('available')
|
|
||||||
async getAvailableIntegrations() {
|
|
||||||
return this.integrationService.getAvailableIntegrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('list')
|
|
||||||
async getInstalledIntegrations(
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
return this.integrationService.getInstalledIntegrations(workspace.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('install')
|
|
||||||
async install(
|
|
||||||
@Body() dto: InstallIntegrationDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationService.install(dto.type, workspace.id, user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('uninstall')
|
|
||||||
async uninstall(
|
|
||||||
@Body() dto: UninstallIntegrationDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.integrationService.uninstall(dto.integrationId, workspace.id);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('update')
|
|
||||||
async update(
|
|
||||||
@Body() dto: UpdateIntegrationDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationService.update(dto.integrationId, workspace.id, {
|
|
||||||
settings: dto.settings,
|
|
||||||
isEnabled: dto.isEnabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('connection/status')
|
|
||||||
async getConnectionStatus(
|
|
||||||
@Body() dto: IntegrationIdDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
return this.connectionService.getConnectionStatus(
|
|
||||||
dto.integrationId,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
|
||||||
import { EventName } from '../../common/events/event.contants';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationListener {
|
|
||||||
constructor(
|
|
||||||
@InjectQueue(QueueName.INTEGRATION_QUEUE)
|
|
||||||
private readonly integrationQueue: Queue,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_CREATED)
|
|
||||||
async onPageCreated(payload: any) {
|
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
|
||||||
eventName: EventName.PAGE_CREATED,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_UPDATED)
|
|
||||||
async onPageUpdated(payload: any) {
|
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
|
||||||
eventName: EventName.PAGE_UPDATED,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_DELETED)
|
|
||||||
async onPageDeleted(payload: any) {
|
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
|
||||||
eventName: EventName.PAGE_DELETED,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
|
||||||
import { IntegrationService } from './integration.service';
|
|
||||||
import { IntegrationConnectionService } from './integration-connection.service';
|
|
||||||
import { IntegrationController } from './integration.controller';
|
|
||||||
import { OAuthController } from './oauth/oauth.controller';
|
|
||||||
import { OAuthService } from './oauth/oauth.service';
|
|
||||||
import { UnfurlController } from './unfurl/unfurl.controller';
|
|
||||||
import { UnfurlService } from './unfurl/unfurl.service';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
|
||||||
import { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
|
|
||||||
import { IntegrationListener } from './integration.listener';
|
|
||||||
import { IntegrationProcessor } from './integration.processor';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [IntegrationController, OAuthController, UnfurlController],
|
|
||||||
providers: [
|
|
||||||
IntegrationRegistry,
|
|
||||||
IntegrationService,
|
|
||||||
IntegrationConnectionService,
|
|
||||||
OAuthService,
|
|
||||||
UnfurlService,
|
|
||||||
IntegrationRepo,
|
|
||||||
IntegrationConnectionRepo,
|
|
||||||
IntegrationWebhookRepo,
|
|
||||||
IntegrationListener,
|
|
||||||
IntegrationProcessor,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
IntegrationRegistry,
|
|
||||||
IntegrationService,
|
|
||||||
IntegrationConnectionService,
|
|
||||||
OAuthService,
|
|
||||||
IntegrationRepo,
|
|
||||||
IntegrationConnectionRepo,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class IntegrationModule {}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
|
||||||
import { OAuthService } from './oauth/oauth.service';
|
|
||||||
|
|
||||||
@Processor(QueueName.INTEGRATION_QUEUE)
|
|
||||||
export class IntegrationProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(IntegrationProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
private readonly oauthService: OAuthService,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job): Promise<void> {
|
|
||||||
switch (job.name) {
|
|
||||||
case QueueJob.INTEGRATION_EVENT:
|
|
||||||
await this.handleIntegrationEvent(job);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.warn(`Unknown job: ${job.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleIntegrationEvent(job: Job): Promise<void> {
|
|
||||||
const { eventName, workspaceId, ...payload } = job.data;
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations =
|
|
||||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
|
||||||
|
|
||||||
for (const integration of integrations) {
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider?.handleEvent) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const connections = await this.connectionRepo.findByIntegration(
|
|
||||||
integration.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const connection = connections[0];
|
|
||||||
let accessToken: string | undefined;
|
|
||||||
|
|
||||||
if (connection) {
|
|
||||||
accessToken = await this.oauthService.getValidAccessToken(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
await provider.handleEvent({
|
|
||||||
eventName,
|
|
||||||
payload,
|
|
||||||
integration: {
|
|
||||||
id: integration.id,
|
|
||||||
type: integration.type,
|
|
||||||
settings: integration.settings as Record<string, any> | null,
|
|
||||||
},
|
|
||||||
connection: connection
|
|
||||||
? { accessToken, userId: connection.userId }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
|
||||||
import { Integration } from '@docmost/db/types/entity.types';
|
|
||||||
import { validateIntegrationSettings } from './dto/integration-settings.schema';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationService {
|
|
||||||
constructor(
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getAvailableIntegrations() {
|
|
||||||
return this.registry.getAvailableIntegrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInstalledIntegrations(workspaceId: string): Promise<Integration[]> {
|
|
||||||
return this.integrationRepo.findAllByWorkspace(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(integrationId: string): Promise<Integration | undefined> {
|
|
||||||
return this.integrationRepo.findById(integrationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async install(
|
|
||||||
type: string,
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const provider = this.registry.getProvider(type);
|
|
||||||
if (!provider) {
|
|
||||||
throw new BadRequestException(`Unknown integration type: ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await this.integrationRepo.findByWorkspaceAndType(
|
|
||||||
workspaceId,
|
|
||||||
type,
|
|
||||||
);
|
|
||||||
if (existing) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Integration "${type}" is already installed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationRepo.insertOrRestore({
|
|
||||||
type,
|
|
||||||
workspaceId,
|
|
||||||
installedById: userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async uninstall(integrationId: string, workspaceId: string): Promise<void> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
await this.integrationRepo.softDelete(integrationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
integrationId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
data: { settings?: Record<string, any>; isEnabled?: boolean },
|
|
||||||
): Promise<Integration> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.settings !== undefined) {
|
|
||||||
const validation = validateIntegrationSettings(
|
|
||||||
integration.type,
|
|
||||||
data.settings,
|
|
||||||
);
|
|
||||||
if (validation.success === false) {
|
|
||||||
throw new BadRequestException(`Invalid settings: ${validation.error}`);
|
|
||||||
}
|
|
||||||
data.settings = validation.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationRepo.update(integrationId, {
|
|
||||||
...(data.settings !== undefined && { settings: data.settings }),
|
|
||||||
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Logger,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyReply } from 'fastify';
|
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
|
||||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { OAuthService } from './oauth.service';
|
|
||||||
import { OAuthAuthorizeDto, OAuthDisconnectDto } from '../dto/integration.dto';
|
|
||||||
import { IntegrationConnectionService } from '../integration-connection.service';
|
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
@Controller('integrations/oauth')
|
|
||||||
export class OAuthController {
|
|
||||||
private readonly logger = new Logger(OAuthController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly oauthService: OAuthService,
|
|
||||||
private readonly connectionService: IntegrationConnectionService,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('authorize')
|
|
||||||
async authorize(
|
|
||||||
@Body() dto: OAuthAuthorizeDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const { authorizationUrl } = await this.oauthService.getAuthorizationUrl(
|
|
||||||
dto.integrationId,
|
|
||||||
workspace.id,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { authorizationUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':type/callback')
|
|
||||||
async callback(
|
|
||||||
@Param('type') type: string,
|
|
||||||
@Query('code') code: string,
|
|
||||||
@Query('state') state: string,
|
|
||||||
@Res() res: FastifyReply,
|
|
||||||
) {
|
|
||||||
if (!code || !state) {
|
|
||||||
throw new BadRequestException('Missing code or state parameter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const statePayload = this.oauthService.verifySignedState(state);
|
|
||||||
if (!statePayload) {
|
|
||||||
throw new BadRequestException('Invalid or expired OAuth state');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.oauthService.exchangeCodeForTokens(
|
|
||||||
type,
|
|
||||||
code,
|
|
||||||
statePayload.integrationId,
|
|
||||||
statePayload.userId,
|
|
||||||
statePayload.workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appUrl = this.environmentService.getAppUrl();
|
|
||||||
return res.redirect(`${appUrl}/settings/integrations`, 302).send();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`);
|
|
||||||
const appUrl = this.environmentService.getAppUrl();
|
|
||||||
return res.redirect(`${appUrl}/settings/integrations?error=oauth_failed`, 302).send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('disconnect')
|
|
||||||
async disconnect(
|
|
||||||
@Body() dto: OAuthDisconnectDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
await this.connectionService.disconnect(
|
|
||||||
dto.integrationId,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
|
||||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
|
||||||
import { IntegrationRepo } from '../repos/integration.repo';
|
|
||||||
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
|
|
||||||
import { encryptToken, decryptToken } from '../crypto/token-crypto';
|
|
||||||
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
|
||||||
import { OAuthConfig } from '../registry/integration-provider.interface';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
type OAuthTokenResponse = {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
expires_in?: number;
|
|
||||||
token_type?: string;
|
|
||||||
scope?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OAuthStatePayload = {
|
|
||||||
integrationId: string;
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
exp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OAuthService {
|
|
||||||
private readonly logger = new Logger(OAuthService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getAuthorizationUrl(
|
|
||||||
integrationId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<{ authorizationUrl: string }> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider || !provider.definition.oauth) {
|
|
||||||
throw new BadRequestException('Integration does not support OAuth');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauthConfig = provider.getOAuthConfig
|
|
||||||
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
|
|
||||||
: provider.definition.oauth;
|
|
||||||
|
|
||||||
const callbackUrl = this.buildCallbackUrl(integration.type);
|
|
||||||
|
|
||||||
const state = this.createSignedState({
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
exp: Date.now() + 10 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: this.getClientId(integration.type),
|
|
||||||
redirect_uri: callbackUrl,
|
|
||||||
response_type: 'code',
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scope = oauthConfig.scopes
|
|
||||||
.map((s) => encodeURIComponent(s))
|
|
||||||
.join('%20');
|
|
||||||
|
|
||||||
return {
|
|
||||||
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
verifySignedState(state: string): OAuthStatePayload | null {
|
|
||||||
const dotIndex = state.lastIndexOf('.');
|
|
||||||
if (dotIndex === -1) return null;
|
|
||||||
|
|
||||||
const data = state.substring(0, dotIndex);
|
|
||||||
const signature = state.substring(dotIndex + 1);
|
|
||||||
|
|
||||||
const secret = this.environmentService.getAppSecret();
|
|
||||||
const expected = crypto
|
|
||||||
.createHmac('sha256', secret)
|
|
||||||
.update(data)
|
|
||||||
.digest('base64url');
|
|
||||||
|
|
||||||
if (signature !== expected) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: OAuthStatePayload = JSON.parse(
|
|
||||||
Buffer.from(data, 'base64url').toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (payload.exp < Date.now()) return null;
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async exchangeCodeForTokens(
|
|
||||||
type: string,
|
|
||||||
code: string,
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<IntegrationConnection> {
|
|
||||||
const provider = this.registry.getProvider(type);
|
|
||||||
if (!provider || !provider.definition.oauth) {
|
|
||||||
throw new BadRequestException('Integration does not support OAuth');
|
|
||||||
}
|
|
||||||
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
const settings = (integration?.settings as Record<string, any>) ?? {};
|
|
||||||
|
|
||||||
const oauthConfig = provider.getOAuthConfig
|
|
||||||
? provider.getOAuthConfig(settings)
|
|
||||||
: provider.definition.oauth;
|
|
||||||
|
|
||||||
const tokenResponse = await this.requestTokens(
|
|
||||||
oauthConfig,
|
|
||||||
type,
|
|
||||||
code,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appSecret = this.environmentService.getAppSecret();
|
|
||||||
const encryptedAccessToken = encryptToken(
|
|
||||||
tokenResponse.access_token,
|
|
||||||
appSecret,
|
|
||||||
);
|
|
||||||
const encryptedRefreshToken = tokenResponse.refresh_token
|
|
||||||
? encryptToken(tokenResponse.refresh_token, appSecret)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const tokenExpiresAt = tokenResponse.expires_in
|
|
||||||
? new Date(Date.now() + tokenResponse.expires_in * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const connection = await this.connectionRepo.upsert({
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
accessToken: encryptedAccessToken,
|
|
||||||
refreshToken: encryptedRefreshToken,
|
|
||||||
tokenExpiresAt,
|
|
||||||
scopes: tokenResponse.scope ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (provider.onConnected) {
|
|
||||||
await provider.onConnected({
|
|
||||||
accessToken: tokenResponse.access_token,
|
|
||||||
refreshToken: tokenResponse.refresh_token,
|
|
||||||
providerUserId: '',
|
|
||||||
metadata: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getValidAccessToken(
|
|
||||||
connection: IntegrationConnection,
|
|
||||||
): Promise<string> {
|
|
||||||
const appSecret = this.environmentService.getAppSecret();
|
|
||||||
const accessToken = decryptToken(connection.accessToken, appSecret);
|
|
||||||
|
|
||||||
const needsRefresh =
|
|
||||||
connection.tokenExpiresAt &&
|
|
||||||
connection.refreshToken &&
|
|
||||||
new Date(connection.tokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000;
|
|
||||||
|
|
||||||
if (!needsRefresh) {
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.refreshAccessToken(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshAccessToken(
|
|
||||||
connection: IntegrationConnection,
|
|
||||||
): Promise<string> {
|
|
||||||
const appSecret = this.environmentService.getAppSecret();
|
|
||||||
const refreshToken = decryptToken(connection.refreshToken, appSecret);
|
|
||||||
|
|
||||||
const integration = await this.integrationRepo.findById(
|
|
||||||
connection.integrationId,
|
|
||||||
);
|
|
||||||
if (!integration) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider || !provider.definition.oauth) {
|
|
||||||
throw new BadRequestException('Integration does not support OAuth');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauthConfig = provider.getOAuthConfig
|
|
||||||
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
|
|
||||||
: provider.definition.oauth;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
client_id: this.getClientId(integration.type),
|
|
||||||
client_secret: this.getClientSecret(integration.type),
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(oauthConfig.tokenUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
this.logger.error(
|
|
||||||
`Token refresh failed for ${integration.type}: ${response.status}`,
|
|
||||||
);
|
|
||||||
throw new BadRequestException('Token refresh failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: OAuthTokenResponse = await response.json();
|
|
||||||
const encryptedAccessToken = encryptToken(data.access_token, appSecret);
|
|
||||||
const encryptedRefreshToken = data.refresh_token
|
|
||||||
? encryptToken(data.refresh_token, appSecret)
|
|
||||||
: connection.refreshToken;
|
|
||||||
const tokenExpiresAt = data.expires_in
|
|
||||||
? new Date(Date.now() + data.expires_in * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await this.connectionRepo.update(connection.id, {
|
|
||||||
accessToken: encryptedAccessToken,
|
|
||||||
refreshToken: encryptedRefreshToken,
|
|
||||||
tokenExpiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.access_token;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Token refresh error: ${(err as Error).message}`);
|
|
||||||
throw new BadRequestException('Failed to refresh token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestTokens(
|
|
||||||
oauthConfig: OAuthConfig,
|
|
||||||
type: string,
|
|
||||||
code: string,
|
|
||||||
): Promise<OAuthTokenResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
client_id: this.getClientId(type),
|
|
||||||
client_secret: this.getClientSecret(type),
|
|
||||||
code,
|
|
||||||
redirect_uri: this.buildCallbackUrl(type),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(oauthConfig.tokenUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text();
|
|
||||||
this.logger.error(`Token exchange failed for ${type}: ${response.status} ${body}`);
|
|
||||||
throw new BadRequestException('OAuth token exchange failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCallbackUrl(type: string): string {
|
|
||||||
const appUrl = this.environmentService.getAppUrl();
|
|
||||||
return `${appUrl}/api/integrations/oauth/${type}/callback`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createSignedState(payload: OAuthStatePayload): string {
|
|
||||||
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
||||||
const secret = this.environmentService.getAppSecret();
|
|
||||||
const signature = crypto
|
|
||||||
.createHmac('sha256', secret)
|
|
||||||
.update(data)
|
|
||||||
.digest('base64url');
|
|
||||||
return `${data}.${signature}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClientId(type: string): string {
|
|
||||||
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_ID`;
|
|
||||||
const value = process.env[envKey];
|
|
||||||
if (!value) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Missing environment variable: ${envKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClientSecret(type: string): string {
|
|
||||||
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_SECRET`;
|
|
||||||
const value = process.env[envKey];
|
|
||||||
if (!value) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Missing environment variable: ${envKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions' | 'webhooks';
|
|
||||||
|
|
||||||
export type OAuthConfig = {
|
|
||||||
authUrl: string;
|
|
||||||
tokenUrl: string;
|
|
||||||
scopes: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlPattern = {
|
|
||||||
regex: RegExp;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlResult = {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
url: string;
|
|
||||||
provider: string;
|
|
||||||
providerIcon?: string;
|
|
||||||
status?: string;
|
|
||||||
statusColor?: string;
|
|
||||||
author?: string;
|
|
||||||
authorAvatarUrl?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IntegrationDefinition = {
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
capabilities: IntegrationCapability[];
|
|
||||||
oauth?: OAuthConfig;
|
|
||||||
unfurlPatterns?: UnfurlPattern[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConnectedEvent = {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
providerUserId: string;
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HandleEventOpts = {
|
|
||||||
eventName: string;
|
|
||||||
payload: Record<string, any>;
|
|
||||||
integration: {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
settings: Record<string, any> | null;
|
|
||||||
};
|
|
||||||
connection?: {
|
|
||||||
accessToken: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlOpts = {
|
|
||||||
url: string;
|
|
||||||
accessToken: string;
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
patternType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export abstract class IntegrationProvider {
|
|
||||||
abstract definition: IntegrationDefinition;
|
|
||||||
|
|
||||||
getOAuthConfig?(
|
|
||||||
workspaceSettings: Record<string, any>,
|
|
||||||
): OAuthConfig;
|
|
||||||
|
|
||||||
getUnfurlPatterns?(
|
|
||||||
workspaceSettings: Record<string, any>,
|
|
||||||
): UnfurlPattern[];
|
|
||||||
|
|
||||||
onConnected?(opts: ConnectedEvent): Promise<void>;
|
|
||||||
|
|
||||||
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
|
|
||||||
|
|
||||||
handleEvent?(opts: HandleEventOpts): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
IntegrationDefinition,
|
|
||||||
IntegrationProvider,
|
|
||||||
} from './integration-provider.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationRegistry {
|
|
||||||
private providers = new Map<string, IntegrationProvider>();
|
|
||||||
|
|
||||||
register(provider: IntegrationProvider): void {
|
|
||||||
this.providers.set(provider.definition.type, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
getProvider(type: string): IntegrationProvider | undefined {
|
|
||||||
return this.providers.get(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllProviders(): IntegrationProvider[] {
|
|
||||||
return Array.from(this.providers.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
getAvailableIntegrations(): IntegrationDefinition[] {
|
|
||||||
return this.getAllProviders().map((p) => p.definition);
|
|
||||||
}
|
|
||||||
|
|
||||||
findUnfurlProvider(
|
|
||||||
url: string,
|
|
||||||
): {
|
|
||||||
provider: IntegrationProvider;
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
patternType: string;
|
|
||||||
} | null {
|
|
||||||
for (const provider of this.providers.values()) {
|
|
||||||
if (!provider.definition.unfurlPatterns) continue;
|
|
||||||
for (const pattern of provider.definition.unfurlPatterns) {
|
|
||||||
const match = url.match(pattern.regex);
|
|
||||||
if (match) {
|
|
||||||
return { provider, match, patternType: pattern.type };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import {
|
|
||||||
IntegrationConnection,
|
|
||||||
InsertableIntegrationConnection,
|
|
||||||
UpdatableIntegrationConnection,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationConnectionRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
connectionId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', connectionId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegrationAndUser(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.selectAll()
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.where('userId', '=', userId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByWorkspaceTypeAndUser(
|
|
||||||
workspaceId: string,
|
|
||||||
integrationType: string,
|
|
||||||
userId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.innerJoin(
|
|
||||||
'integrations',
|
|
||||||
'integrations.id',
|
|
||||||
'integrationConnections.integrationId',
|
|
||||||
)
|
|
||||||
.selectAll('integrationConnections')
|
|
||||||
.where('integrations.workspaceId', '=', workspaceId)
|
|
||||||
.where('integrations.type', '=', integrationType)
|
|
||||||
.where('integrations.deletedAt', 'is', null)
|
|
||||||
.where('integrationConnections.userId', '=', userId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.selectAll()
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsert(
|
|
||||||
connection: InsertableIntegrationConnection,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrationConnections')
|
|
||||||
.values(connection)
|
|
||||||
.onConflict((oc) =>
|
|
||||||
oc.columns(['integrationId', 'userId']).doUpdateSet({
|
|
||||||
accessToken: connection.accessToken,
|
|
||||||
refreshToken: connection.refreshToken,
|
|
||||||
tokenExpiresAt: connection.tokenExpiresAt,
|
|
||||||
scopes: connection.scopes,
|
|
||||||
providerUserId: connection.providerUserId,
|
|
||||||
metadata: connection.metadata,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
connectionId: string,
|
|
||||||
data: UpdatableIntegrationConnection,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('integrationConnections')
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where('id', '=', connectionId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByIntegrationAndUser(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationConnections')
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.where('userId', '=', userId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationConnections')
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import {
|
|
||||||
IntegrationWebhook,
|
|
||||||
InsertableIntegrationWebhook,
|
|
||||||
UpdatableIntegrationWebhook,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationWebhookRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
webhookId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationWebhooks')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', webhookId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationWebhooks')
|
|
||||||
.selectAll()
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findEnabledByEvent(
|
|
||||||
workspaceId: string,
|
|
||||||
eventType: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationWebhooks')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('eventType', '=', eventType)
|
|
||||||
.where('isEnabled', '=', true)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insert(
|
|
||||||
webhook: InsertableIntegrationWebhook,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrationWebhooks')
|
|
||||||
.values(webhook)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
webhookId: string,
|
|
||||||
data: UpdatableIntegrationWebhook,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('integrationWebhooks')
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where('id', '=', webhookId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(
|
|
||||||
webhookId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationWebhooks')
|
|
||||||
.where('id', '=', webhookId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationWebhooks')
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import {
|
|
||||||
Integration,
|
|
||||||
InsertableIntegration,
|
|
||||||
UpdatableIntegration,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', integrationId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByWorkspaceAndType(
|
|
||||||
workspaceId: string,
|
|
||||||
type: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('type', '=', type)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findEnabledByWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('isEnabled', '=', true)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllByWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insert(
|
|
||||||
integration: InsertableIntegration,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrations')
|
|
||||||
.values(integration)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertOrRestore(
|
|
||||||
integration: InsertableIntegration,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrations')
|
|
||||||
.values(integration)
|
|
||||||
.onConflict((oc) =>
|
|
||||||
oc.columns(['type', 'workspaceId']).doUpdateSet({
|
|
||||||
deletedAt: null,
|
|
||||||
isEnabled: true,
|
|
||||||
installedById: integration.installedById,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
integrationId: string,
|
|
||||||
data: UpdatableIntegration,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('integrations')
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where('id', '=', integrationId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async softDelete(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.updateTable('integrations')
|
|
||||||
.set({ deletedAt: new Date() })
|
|
||||||
.where('id', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
|
||||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { UnfurlService } from './unfurl.service';
|
|
||||||
import { UnfurlDto } from '../dto/integration.dto';
|
|
||||||
|
|
||||||
@Controller('integrations')
|
|
||||||
export class UnfurlController {
|
|
||||||
constructor(private readonly unfurlService: UnfurlService) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('unfurl')
|
|
||||||
async unfurl(
|
|
||||||
@Body() dto: UnfurlDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const result = await this.unfurlService.unfurl(
|
|
||||||
dto.url,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
return { data: result };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
|
||||||
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
|
|
||||||
import { IntegrationRepo } from '../repos/integration.repo';
|
|
||||||
import { OAuthService } from '../oauth/oauth.service';
|
|
||||||
import {
|
|
||||||
UnfurlResult,
|
|
||||||
IntegrationProvider,
|
|
||||||
} from '../registry/integration-provider.interface';
|
|
||||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
|
||||||
import type { Redis } from 'ioredis';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
const UNFURL_CACHE_TTL = 300; // 5 minutes
|
|
||||||
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UnfurlService {
|
|
||||||
private readonly logger = new Logger(UnfurlService.name);
|
|
||||||
private readonly redis: Redis;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
private readonly oauthService: OAuthService,
|
|
||||||
private readonly redisService: RedisService,
|
|
||||||
) {
|
|
||||||
this.redis = this.redisService.getOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async unfurl(
|
|
||||||
url: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<UnfurlResult | null> {
|
|
||||||
const cacheKey = this.buildCacheKey(workspaceId, url);
|
|
||||||
const cached = await this.redis.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return JSON.parse(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await this.resolveProvider(url, workspaceId);
|
|
||||||
|
|
||||||
if (!resolved) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { provider, match, patternType, integration } = resolved;
|
|
||||||
|
|
||||||
if (!provider.unfurl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await this.connectionRepo.findByIntegrationAndUser(
|
|
||||||
integration.id,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connection) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessToken =
|
|
||||||
await this.oauthService.getValidAccessToken(connection);
|
|
||||||
|
|
||||||
const unfurlResult = await provider.unfurl({
|
|
||||||
url,
|
|
||||||
accessToken,
|
|
||||||
match,
|
|
||||||
patternType,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.redis.set(
|
|
||||||
cacheKey,
|
|
||||||
JSON.stringify(unfurlResult),
|
|
||||||
'EX',
|
|
||||||
UNFURL_CACHE_TTL,
|
|
||||||
);
|
|
||||||
|
|
||||||
return unfurlResult;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveProvider(
|
|
||||||
url: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<{
|
|
||||||
provider: IntegrationProvider;
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
patternType: string;
|
|
||||||
integration: { id: string; isEnabled: boolean; type: string };
|
|
||||||
} | null> {
|
|
||||||
const staticResult = this.registry.findUnfurlProvider(url);
|
|
||||||
if (staticResult) {
|
|
||||||
const integration = await this.integrationRepo.findByWorkspaceAndType(
|
|
||||||
workspaceId,
|
|
||||||
staticResult.provider.definition.type,
|
|
||||||
);
|
|
||||||
if (integration && integration.isEnabled) {
|
|
||||||
return { ...staticResult, integration };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations =
|
|
||||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
|
||||||
|
|
||||||
for (const integration of integrations) {
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider?.getUnfurlPatterns || !provider.unfurl) continue;
|
|
||||||
|
|
||||||
const settings = (integration.settings as Record<string, any>) ?? {};
|
|
||||||
const patterns = provider.getUnfurlPatterns(settings);
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = url.match(pattern.regex);
|
|
||||||
if (match) {
|
|
||||||
return { provider, match, patternType: pattern.type, integration };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCacheKey(workspaceId: string, url: string): string {
|
|
||||||
const hash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(url)
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 16);
|
|
||||||
return `${UNFURL_CACHE_PREFIX}${workspaceId}:${hash}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,9 @@ import { NotificationController } from './notification.controller';
|
|||||||
import { NotificationProcessor } from './notification.processor';
|
import { NotificationProcessor } from './notification.processor';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
import { WsModule } from '../../ws/ws.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WsModule],
|
imports: [],
|
||||||
controllers: [NotificationController],
|
controllers: [NotificationController],
|
||||||
providers: [
|
providers: [
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
|||||||
@@ -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,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PageService } from './services/page.service';
|
import { PageService } from './services/page.service';
|
||||||
|
import { PageAccessService } from '../page-access/page-access.service';
|
||||||
import { CreatePageDto } from './dto/create-page.dto';
|
import { CreatePageDto } from './dto/create-page.dto';
|
||||||
import { UpdatePageDto } from './dto/update-page.dto';
|
import { UpdatePageDto } from './dto/update-page.dto';
|
||||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||||
@@ -48,6 +49,7 @@ export class PageController {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageHistoryService: PageHistoryService,
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly pageAccessService: PageAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -65,10 +67,10 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const { canEdit, hasRestriction } =
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
await this.pageAccessService.validateCanViewWithPermissions(page, user);
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
const permissions = { canEdit, hasRestriction };
|
||||||
|
|
||||||
if (dto.format && dto.format !== 'json' && page.content) {
|
if (dto.format && dto.format !== 'json' && page.content) {
|
||||||
const contentOutput =
|
const contentOutput =
|
||||||
@@ -78,10 +80,11 @@ export class PageController {
|
|||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
content: contentOutput,
|
content: contentOutput,
|
||||||
|
permissions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return page;
|
return { ...page, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -91,12 +94,28 @@ export class PageController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const ability = await this.spaceAbility.createForUser(
|
if (createPageDto.parentPageId) {
|
||||||
user,
|
// Creating under a parent page - check edit permission on parent
|
||||||
createPageDto.spaceId,
|
const parentPage = await this.pageRepo.findById(
|
||||||
);
|
createPageDto.parentPageId,
|
||||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
);
|
||||||
throw new ForbiddenException();
|
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(
|
const page = await this.pageService.create(
|
||||||
@@ -105,6 +124,11 @@ export class PageController {
|
|||||||
createPageDto,
|
createPageDto,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { canEdit, hasRestriction } =
|
||||||
|
await this.pageAccessService.validateCanViewWithPermissions(page, user);
|
||||||
|
|
||||||
|
const permissions = { canEdit, hasRestriction };
|
||||||
|
|
||||||
if (
|
if (
|
||||||
createPageDto.format &&
|
createPageDto.format &&
|
||||||
createPageDto.format !== 'json' &&
|
createPageDto.format !== 'json' &&
|
||||||
@@ -114,10 +138,10 @@ export class PageController {
|
|||||||
createPageDto.format === 'markdown'
|
createPageDto.format === 'markdown'
|
||||||
? jsonToMarkdown(page.content)
|
? jsonToMarkdown(page.content)
|
||||||
: jsonToHtml(page.content);
|
: jsonToHtml(page.content);
|
||||||
return { ...page, content: contentOutput };
|
return { ...page, content: contentOutput, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
return page;
|
return { ...page, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -129,10 +153,8 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const { hasRestriction } =
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedPage = await this.pageService.update(
|
const updatedPage = await this.pageService.update(
|
||||||
page,
|
page,
|
||||||
@@ -140,6 +162,8 @@ export class PageController {
|
|||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const permissions = { canEdit: true, hasRestriction };
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updatePageDto.format &&
|
updatePageDto.format &&
|
||||||
updatePageDto.format !== 'json' &&
|
updatePageDto.format !== 'json' &&
|
||||||
@@ -149,10 +173,10 @@ export class PageController {
|
|||||||
updatePageDto.format === 'markdown'
|
updatePageDto.format === 'markdown'
|
||||||
? jsonToMarkdown(updatedPage.content)
|
? jsonToMarkdown(updatedPage.content)
|
||||||
: jsonToHtml(updatedPage.content);
|
: jsonToHtml(updatedPage.content);
|
||||||
return { ...updatedPage, content: contentOutput };
|
return { ...updatedPage, content: contentOutput, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedPage;
|
return { ...updatedPage, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -179,10 +203,9 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
||||||
} else {
|
} else {
|
||||||
// Soft delete requires page manage permissions
|
// User with edit permission can delete
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
await this.pageService.removePage(
|
await this.pageService.removePage(
|
||||||
deletePageDto.pageId,
|
deletePageDto.pageId,
|
||||||
user.id,
|
user.id,
|
||||||
@@ -204,11 +227,18 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
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);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
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);
|
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
|
||||||
|
|
||||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||||
@@ -235,6 +265,7 @@ export class PageController {
|
|||||||
|
|
||||||
return this.pageService.getRecentSpacePages(
|
return this.pageService.getRecentSpacePages(
|
||||||
recentPageDto.spaceId,
|
recentPageDto.spaceId,
|
||||||
|
user.id,
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -261,6 +292,7 @@ export class PageController {
|
|||||||
|
|
||||||
return this.pageService.getDeletedSpacePages(
|
return this.pageService.getDeletedSpacePages(
|
||||||
deletedPageDto.spaceId,
|
deletedPageDto.spaceId,
|
||||||
|
user.id,
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,10 +310,7 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
||||||
}
|
}
|
||||||
@@ -297,13 +326,14 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page history not found');
|
throw new NotFoundException('Page history not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(
|
// Get the page to check permissions
|
||||||
user,
|
const page = await this.pageRepo.findById(history.pageId);
|
||||||
history.spaceId,
|
if (!page) {
|
||||||
);
|
throw new NotFoundException('Page not found');
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +365,18 @@ export class PageController {
|
|||||||
throw new ForbiddenException();
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -365,7 +406,11 @@ export class PageController {
|
|||||||
throw new ForbiddenException();
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -376,6 +421,10 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page to copy not found');
|
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 spaceId is provided, it's a copy to different space
|
||||||
if (dto.spaceId) {
|
if (dto.spaceId) {
|
||||||
const abilities = await Promise.all([
|
const abilities = await Promise.all([
|
||||||
@@ -418,10 +467,23 @@ export class PageController {
|
|||||||
user,
|
user,
|
||||||
movedPage.spaceId,
|
movedPage.spaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
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);
|
return this.pageService.movePage(dto, movedPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,10 +495,8 @@ export class PageController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
return this.pageService.getPageBreadCrumbs(page.id);
|
return this.pageService.getPageBreadCrumbs(page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
||||||
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
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 { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||||
import { markdownToHtml } from '@docmost/editor-ext';
|
import { markdownToHtml } from '@docmost/editor-ext';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -55,6 +57,7 @@ export class PageService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
private pagePermissionRepo: PagePermissionRepo,
|
||||||
private attachmentRepo: AttachmentRepo,
|
private attachmentRepo: AttachmentRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@@ -92,7 +95,11 @@ export class PageService {
|
|||||||
createPageDto.parentPageId,
|
createPageDto.parentPageId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
|
if (
|
||||||
|
!parentPage ||
|
||||||
|
parentPage.deletedAt ||
|
||||||
|
parentPage.spaceId !== createPageDto.spaceId
|
||||||
|
) {
|
||||||
throw new NotFoundException('Parent page not found');
|
throw new NotFoundException('Parent page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +269,8 @@ export class PageService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
pageId?: string,
|
pageId?: string,
|
||||||
|
userId?: string,
|
||||||
|
spaceCanEdit?: boolean,
|
||||||
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
||||||
let query = this.db
|
let query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -286,7 +295,7 @@ export class PageService {
|
|||||||
query = query.where('parentPageId', 'is', null);
|
query = query.where('parentPageId', 'is', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = await executeWithCursorPagination(query, {
|
||||||
perPage: 250,
|
perPage: 250,
|
||||||
cursor: pagination.cursor,
|
cursor: pagination.cursor,
|
||||||
beforeCursor: pagination.beforeCursor,
|
beforeCursor: pagination.beforeCursor,
|
||||||
@@ -303,10 +312,97 @@ export class PageService {
|
|||||||
id: cursor.id,
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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) => {
|
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
|
// Update root page
|
||||||
const nextPosition = await this.nextPagePosition(spaceId);
|
const nextPosition = await this.nextPagePosition(spaceId);
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
@@ -314,48 +410,54 @@ export class PageService {
|
|||||||
rootPage.id,
|
rootPage.id,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
const pageIds = await this.pageRepo
|
|
||||||
.getPageAndDescendants(rootPage.id, { includeContent: false })
|
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
||||||
.then((pages) => pages.map((page) => page.id));
|
|
||||||
// The first id is the root page id
|
if (pageIdsToMove.length > 1) {
|
||||||
if (pageIds.length > 1) {
|
// Update sub pages (all accessible pages except root)
|
||||||
// Update sub pages
|
|
||||||
await this.pageRepo.updatePages(
|
await this.pageRepo.updatePages(
|
||||||
{ spaceId },
|
{ spaceId },
|
||||||
pageIds.filter((id) => id !== rootPage.id),
|
pageIdsToMove.filter((id) => id !== rootPage.id),
|
||||||
trx,
|
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
|
// update spaceId in shares
|
||||||
await trx
|
await trx
|
||||||
.updateTable('shares')
|
.updateTable('shares')
|
||||||
.set({ spaceId: spaceId })
|
.set({ spaceId: spaceId })
|
||||||
.where('pageId', 'in', pageIds)
|
.where('pageId', 'in', pageIdsToMove)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Update comments
|
// Update comments
|
||||||
await trx
|
await trx
|
||||||
.updateTable('comments')
|
.updateTable('comments')
|
||||||
.set({ spaceId: spaceId })
|
.set({ spaceId: spaceId })
|
||||||
.where('pageId', 'in', pageIds)
|
.where('pageId', 'in', pageIdsToMove)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Update attachments
|
// Update attachments
|
||||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||||
{ spaceId },
|
{ spaceId },
|
||||||
pageIds,
|
pageIdsToMove,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update watchers and remove those without access to new space
|
// Update watchers and remove those without access to new space
|
||||||
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
|
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageId: pageIds,
|
pageId: pageIdsToMove,
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: rootPage.workspaceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -381,10 +483,18 @@ export class PageService {
|
|||||||
nextPosition = await this.nextPagePosition(spaceId);
|
nextPosition = await this.nextPagePosition(spaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||||
includeContent: true,
|
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>();
|
const pageMap = new Map<string, CopyPageMapEntry>();
|
||||||
pages.forEach((page) => {
|
pages.forEach((page) => {
|
||||||
pageMap.set(page.id, {
|
pageMap.set(page.id, {
|
||||||
@@ -592,7 +702,11 @@ export class PageService {
|
|||||||
// changing the page's parent
|
// changing the page's parent
|
||||||
if (dto.parentPageId) {
|
if (dto.parentPageId) {
|
||||||
const parentPage = await this.pageRepo.findById(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');
|
throw new NotFoundException('Parent page not found');
|
||||||
}
|
}
|
||||||
parentPageId = parentPage.id;
|
parentPageId = parentPage.id;
|
||||||
@@ -623,7 +737,6 @@ export class PageService {
|
|||||||
'spaceId',
|
'spaceId',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
])
|
])
|
||||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
|
||||||
.where('id', '=', childPageId)
|
.where('id', '=', childPageId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
@@ -639,30 +752,21 @@ export class PageService {
|
|||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.deletedAt',
|
'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')
|
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||||
.where('p.deletedAt', 'is', null),
|
.where('p.deletedAt', 'is', null),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('page_ancestors')
|
.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();
|
.execute();
|
||||||
|
|
||||||
return ancestors.reverse();
|
return ancestors.reverse();
|
||||||
@@ -670,23 +774,72 @@ export class PageService {
|
|||||||
|
|
||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): 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(
|
async getRecentPages(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): 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(
|
async getDeletedSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): 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> {
|
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
||||||
@@ -776,4 +929,61 @@ export class PageService {
|
|||||||
|
|
||||||
return prosemirrorJson;
|
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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const tsquery = require('pg-tsquery')();
|
const tsquery = require('pg-tsquery')();
|
||||||
@@ -18,6 +19,7 @@ export class SearchService {
|
|||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private shareRepo: ShareRepo,
|
private shareRepo: ShareRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private pagePermissionRepo: PagePermissionRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchPage(
|
async searchPage(
|
||||||
@@ -115,10 +117,23 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//@ts-ignore
|
//@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
|
//@ts-ignore
|
||||||
const searchResults = queryResults.map((result: SearchResponseDto) => {
|
const searchResults = results.map((result: SearchResponseDto) => {
|
||||||
if (result.highlight) {
|
if (result.highlight) {
|
||||||
result.highlight = result.highlight
|
result.highlight = result.highlight
|
||||||
.replace(/\r\n|\r|\n/g, ' ')
|
.replace(/\r\n|\r|\n/g, ' ')
|
||||||
@@ -207,6 +222,19 @@ export class SearchService {
|
|||||||
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
|
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
|
||||||
pages = await pageSearch.execute();
|
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 };
|
return { users, groups, pages };
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
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 { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
|
||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
import {
|
import {
|
||||||
CreateShareDto,
|
CreateShareDto,
|
||||||
@@ -26,6 +21,8 @@ import {
|
|||||||
UpdateShareDto,
|
UpdateShareDto,
|
||||||
} from './dto/share.dto';
|
} from './dto/share.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
|
import { PageAccessService } from '../page-access/page-access.service';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
@@ -38,9 +35,10 @@ import { hasLicenseOrEE } from '../../common/helpers';
|
|||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly shareService: ShareService,
|
private readonly shareService: ShareService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
|
||||||
private readonly shareRepo: ShareRepo,
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
|
private readonly pageAccessService: PageAccessService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -119,10 +117,7 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.shareService.getShareForPage(page.id, workspace.id);
|
return this.shareService.getShareForPage(page.id, workspace.id);
|
||||||
}
|
}
|
||||||
@@ -140,9 +135,17 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
// User must be able to edit the page to create a share
|
||||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
|
//TODO: i dont think this is neccessary if we prevent restricted pages from getting shared
|
||||||
throw new ForbiddenException();
|
// 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(
|
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||||
@@ -170,11 +173,14 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
const page = await this.pageRepo.findById(share.pageId);
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
|
if (!page) {
|
||||||
throw new ForbiddenException();
|
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);
|
return this.shareService.updateShare(share.id, updateShareDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,11 +193,14 @@ export class ShareController {
|
|||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
const page = await this.pageRepo.findById(share.pageId);
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
|
if (!page) {
|
||||||
throw new ForbiddenException();
|
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);
|
await this.shareRepo.deleteShare(share.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
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 { updateAttachmentAttr } from './share.util';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
@@ -31,6 +32,7 @@ export class ShareService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly shareRepo: ShareRepo,
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
) {}
|
) {}
|
||||||
@@ -41,12 +43,20 @@ export class ShareService {
|
|||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (share.includeSubPages) {
|
const isRestricted =
|
||||||
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
|
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
||||||
includeContent: false,
|
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 {
|
} else {
|
||||||
return { share, pageTree: [] };
|
return { share, pageTree: [] };
|
||||||
}
|
}
|
||||||
@@ -112,6 +122,13 @@ export class ShareService {
|
|||||||
throw new NotFoundException('Shared page not found');
|
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);
|
page.content = await this.updatePublicAttachments(page);
|
||||||
|
|
||||||
return { page, share };
|
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 { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { PageRepo } from './repos/page/page.repo';
|
import { PageRepo } from './repos/page/page.repo';
|
||||||
|
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
||||||
import { CommentRepo } from './repos/comment/comment.repo';
|
import { CommentRepo } from './repos/comment/comment.repo';
|
||||||
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
||||||
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
||||||
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
SpaceRepo,
|
SpaceRepo,
|
||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
|
PagePermissionRepo,
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
@@ -94,6 +96,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
SpaceRepo,
|
SpaceRepo,
|
||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
|
PagePermissionRepo,
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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('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_workspace')
|
||||||
|
.on('page_access')
|
||||||
|
.column('workspace_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_page_permissions_page_access')
|
||||||
|
.on('page_permissions')
|
||||||
|
.column('page_access_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();
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { type Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('integrations')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('type', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
|
||||||
.addColumn('settings', 'jsonb')
|
|
||||||
.addColumn('installed_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()`),
|
|
||||||
)
|
|
||||||
.addColumn('deleted_at', 'timestamptz')
|
|
||||||
.addUniqueConstraint('uq_integrations_workspace_type', [
|
|
||||||
'workspace_id',
|
|
||||||
'type',
|
|
||||||
])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable('integration_connections')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('integration_id', 'uuid', (col) =>
|
|
||||||
col.references('integrations.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('user_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('provider_user_id', 'text')
|
|
||||||
.addColumn('access_token', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('refresh_token', 'text')
|
|
||||||
.addColumn('token_expires_at', 'timestamptz')
|
|
||||||
.addColumn('scopes', 'text')
|
|
||||||
.addColumn('metadata', 'jsonb')
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addUniqueConstraint('uq_integration_connections_integration_user', [
|
|
||||||
'integration_id',
|
|
||||||
'user_id',
|
|
||||||
])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable('integration_webhooks')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('integration_id', 'uuid', (col) =>
|
|
||||||
col.references('integrations.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('event_type', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('webhook_url', 'text')
|
|
||||||
.addColumn('secret', 'text')
|
|
||||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_integration_webhooks_integration_event')
|
|
||||||
.on('integration_webhooks')
|
|
||||||
.columns(['integration_id', 'event_type'])
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.dropTable('integration_webhooks').execute();
|
|
||||||
await db.schema.dropTable('integration_connections').execute();
|
|
||||||
await db.schema.dropTable('integrations').execute();
|
|
||||||
}
|
|
||||||
@@ -306,6 +306,21 @@ export function defaultEncodeCursor<
|
|||||||
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
|
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<
|
export function defaultDecodeCursor<
|
||||||
DB,
|
DB,
|
||||||
TB extends keyof DB,
|
TB extends keyof DB,
|
||||||
|
|||||||
@@ -175,4 +175,14 @@ export class GroupUserRepo {
|
|||||||
.where('groupId', '=', groupId)
|
.where('groupId', '=', groupId)
|
||||||
.execute();
|
.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')
|
.selectFrom('pages')
|
||||||
.select(['id'])
|
.select(['id'])
|
||||||
.where('id', '=', pageId)
|
.where('id', '=', pageId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
.select(['p.id'])
|
.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')
|
.selectFrom('page_descendants')
|
||||||
@@ -197,6 +199,7 @@ export class PageRepo {
|
|||||||
deletedAt: currentDate,
|
deletedAt: currentDate,
|
||||||
})
|
})
|
||||||
.where('id', 'in', pageIds)
|
.where('id', 'in', pageIds)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
|
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
|
||||||
@@ -472,4 +475,75 @@ export class PageRepo {
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.execute();
|
.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;
|
||||||
+13
-31
@@ -390,41 +390,23 @@ export interface Watchers {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Integrations {
|
export interface PageAccess {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
|
pageId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: string;
|
accessLevel: string;
|
||||||
isEnabled: Generated<boolean>;
|
creatorId: string | null;
|
||||||
settings: Json | null;
|
|
||||||
installedById: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrationConnections {
|
|
||||||
id: Generated<string>;
|
|
||||||
integrationId: string;
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
providerUserId: string | null;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string | null;
|
|
||||||
tokenExpiresAt: Timestamp | null;
|
|
||||||
scopes: string | null;
|
|
||||||
metadata: Json | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationWebhooks {
|
export interface PagePermissions {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
integrationId: string;
|
pageAccessId: string;
|
||||||
workspaceId: string;
|
userId: string | null;
|
||||||
eventType: string;
|
groupId: string | null;
|
||||||
webhookUrl: string | null;
|
role: string;
|
||||||
secret: string | null;
|
addedById: string | null;
|
||||||
isEnabled: Generated<boolean>;
|
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
@@ -440,11 +422,11 @@ export interface DB {
|
|||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
integrationConnections: IntegrationConnections;
|
|
||||||
integrationWebhooks: IntegrationWebhooks;
|
|
||||||
integrations: Integrations;
|
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
|
pageAccess: PageAccess;
|
||||||
|
pageHierarchy: PageHierarchy;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
pagePermissions: PagePermissions;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
import {
|
|
||||||
Integrations,
|
|
||||||
IntegrationConnections,
|
|
||||||
IntegrationWebhooks,
|
|
||||||
} from '@docmost/db/types/db';
|
|
||||||
|
|
||||||
export interface DbInterface extends DB {
|
export interface DbInterface extends DB {
|
||||||
pageEmbeddings: PageEmbeddings;
|
pageEmbeddings: PageEmbeddings;
|
||||||
integrations: Integrations;
|
|
||||||
integrationConnections: IntegrationConnections;
|
|
||||||
integrationWebhooks: IntegrationWebhooks;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import {
|
|||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
Groups,
|
Groups,
|
||||||
Integrations as _Integrations,
|
|
||||||
IntegrationConnections as _IntegrationConnections,
|
|
||||||
IntegrationWebhooks as _IntegrationWebhooks,
|
|
||||||
Notifications,
|
Notifications,
|
||||||
|
PageAccess as _PageAccess,
|
||||||
|
PagePermissions as _PagePermissions,
|
||||||
Pages,
|
Pages,
|
||||||
Spaces,
|
Spaces,
|
||||||
Users,
|
Users,
|
||||||
@@ -147,22 +146,12 @@ export type Watcher = Selectable<Watchers>;
|
|||||||
export type InsertableWatcher = Insertable<Watchers>;
|
export type InsertableWatcher = Insertable<Watchers>;
|
||||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||||
|
|
||||||
// Integration
|
// Page Access
|
||||||
export type Integration = Selectable<_Integrations>;
|
export type PageAccess = Selectable<_PageAccess>;
|
||||||
export type InsertableIntegration = Insertable<_Integrations>;
|
export type InsertablePageAccess = Insertable<_PageAccess>;
|
||||||
export type UpdatableIntegration = Updateable<Omit<_Integrations, 'id'>>;
|
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
|
||||||
|
|
||||||
// Integration Connection
|
// Page Permission
|
||||||
export type IntegrationConnection = Selectable<_IntegrationConnections>;
|
export type PagePermission = Selectable<_PagePermissions>;
|
||||||
export type InsertableIntegrationConnection =
|
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||||
Insertable<_IntegrationConnections>;
|
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||||
export type UpdatableIntegrationConnection = Updateable<
|
|
||||||
Omit<_IntegrationConnections, 'id'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
// Integration Webhook
|
|
||||||
export type IntegrationWebhook = Selectable<_IntegrationWebhooks>;
|
|
||||||
export type InsertableIntegrationWebhook = Insertable<_IntegrationWebhooks>;
|
|
||||||
export type UpdatableIntegrationWebhook = Updateable<
|
|
||||||
Omit<_IntegrationWebhooks, 'id'>
|
|
||||||
>;
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 010a833e1a...028e31724e
@@ -16,6 +16,7 @@ import { User } from '@docmost/db/types/entity.types';
|
|||||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { PageAccessService } from '../../core/page-access/page-access.service';
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
@@ -32,6 +33,7 @@ export class ExportController {
|
|||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly pageAccessService: PageAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -50,16 +52,14 @@ export class ExportController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipFileStream = await this.exportService.exportPages(
|
const zipFileStream = await this.exportService.exportPages(
|
||||||
dto.pageId,
|
dto.pageId,
|
||||||
dto.format,
|
dto.format,
|
||||||
dto.includeAttachments,
|
dto.includeAttachments,
|
||||||
dto.includeChildren,
|
dto.includeChildren,
|
||||||
|
user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||||
@@ -90,6 +90,7 @@ export class ExportController {
|
|||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
dto.format,
|
dto.format,
|
||||||
dto.includeAttachments,
|
dto.includeAttachments,
|
||||||
|
user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.headers({
|
res.headers({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ExportPageMetadata,
|
ExportPageMetadata,
|
||||||
} from '../../common/helpers/types/export-metadata.types';
|
} from '../../common/helpers/types/export-metadata.types';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
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 { Node } from '@tiptap/pm/model';
|
||||||
import { EditorState } from '@tiptap/pm/state';
|
import { EditorState } from '@tiptap/pm/state';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
@@ -44,6 +45,7 @@ export class ExportService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@@ -100,6 +102,8 @@ export class ExportService {
|
|||||||
format: string,
|
format: string,
|
||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
includeChildren: boolean,
|
includeChildren: boolean,
|
||||||
|
userId?: string,
|
||||||
|
ignorePermissions = false,
|
||||||
) {
|
) {
|
||||||
let pages: Page[];
|
let pages: Page[];
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ export class ExportService {
|
|||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
if (page){
|
if (page) {
|
||||||
pages = [page];
|
pages = [page];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,14 +126,38 @@ export class ExportService {
|
|||||||
throw new BadRequestException('No pages to export');
|
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);
|
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
|
// set to null to make export of pages with parentId work
|
||||||
pages[parentPageIndex].parentPageId = null;
|
pages[parentPageIndex].parentPageId = null;
|
||||||
|
|
||||||
const tree = buildTree(pages as Page[]);
|
const tree = buildTree(pages as Page[]);
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
await this.zipPages(tree, format, zip, includeAttachments);
|
await this.zipPages(
|
||||||
|
tree,
|
||||||
|
format,
|
||||||
|
zip,
|
||||||
|
includeAttachments,
|
||||||
|
userId,
|
||||||
|
ignorePermissions,
|
||||||
|
);
|
||||||
|
|
||||||
const zipFile = zip.generateNodeStream({
|
const zipFile = zip.generateNodeStream({
|
||||||
type: 'nodebuffer',
|
type: 'nodebuffer',
|
||||||
@@ -144,6 +172,8 @@ export class ExportService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
format: string,
|
format: string,
|
||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
|
userId?: string,
|
||||||
|
ignorePermissions = false,
|
||||||
) {
|
) {
|
||||||
const space = await this.db
|
const space = await this.db
|
||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
@@ -155,7 +185,7 @@ export class ExportService {
|
|||||||
throw new NotFoundException('Space not found');
|
throw new NotFoundException('Space not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = await this.db
|
let pages = await this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
'pages.id',
|
'pages.id',
|
||||||
@@ -174,11 +204,30 @@ export class ExportService {
|
|||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.execute();
|
.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 tree = buildTree(pages as Page[]);
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
await this.zipPages(tree, format, zip, includeAttachments);
|
await this.zipPages(
|
||||||
|
tree,
|
||||||
|
format,
|
||||||
|
zip,
|
||||||
|
includeAttachments,
|
||||||
|
userId,
|
||||||
|
ignorePermissions,
|
||||||
|
);
|
||||||
|
|
||||||
const zipFile = zip.generateNodeStream({
|
const zipFile = zip.generateNodeStream({
|
||||||
type: 'nodebuffer',
|
type: 'nodebuffer',
|
||||||
@@ -198,6 +247,8 @@ export class ExportService {
|
|||||||
format: string,
|
format: string,
|
||||||
zip: JSZip,
|
zip: JSZip,
|
||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
|
userId?: string,
|
||||||
|
ignorePermissions = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const slugIdToPath: Record<string, string> = {};
|
const slugIdToPath: Record<string, string> = {};
|
||||||
const pageIdToFilePath: Record<string, string> = {};
|
const pageIdToFilePath: Record<string, string> = {};
|
||||||
@@ -219,6 +270,8 @@ export class ExportService {
|
|||||||
const prosemirrorJson = await this.turnPageMentionsToLinks(
|
const prosemirrorJson = await this.turnPageMentionsToLinks(
|
||||||
getProsemirrorContent(page.content),
|
getProsemirrorContent(page.content),
|
||||||
page.workspaceId,
|
page.workspaceId,
|
||||||
|
userId,
|
||||||
|
ignorePermissions,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentPagePath = slugIdToPath[page.slugId];
|
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 doc = jsonToNode(prosemirrorJson);
|
||||||
|
|
||||||
const pageMentionIds = [];
|
let pageMentionIds: string[] = [];
|
||||||
|
|
||||||
doc.descendants((node: Node) => {
|
doc.descendants((node: Node) => {
|
||||||
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
||||||
@@ -320,13 +378,32 @@ export class ExportService {
|
|||||||
return prosemirrorJson;
|
return prosemirrorJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = await this.db
|
// Filter to only accessible pages if permissions are enforced
|
||||||
.selectFrom('pages')
|
if (!ignorePermissions && userId) {
|
||||||
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
|
pageMentionIds =
|
||||||
.select((eb) => this.pageRepo.withSpace(eb))
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
.where('id', 'in', pageMentionIds)
|
pageIds: pageMentionIds,
|
||||||
.where('workspaceId', '=', workspaceId)
|
userId,
|
||||||
.execute();
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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]));
|
const pageMap = new Map(pages.map((page) => [page.id, page]));
|
||||||
|
|
||||||
@@ -398,4 +475,51 @@ export class ExportService {
|
|||||||
|
|
||||||
return updatedDoc.toJSON();
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export enum QueueName {
|
|||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HISTORY_QUEUE = '{history-queue}',
|
HISTORY_QUEUE = '{history-queue}',
|
||||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||||
INTEGRATION_QUEUE = '{integration-queue}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
@@ -68,6 +67,4 @@ export enum QueueJob {
|
|||||||
COMMENT_NOTIFICATION = 'comment-notification',
|
COMMENT_NOTIFICATION = 'comment-notification',
|
||||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||||
|
|
||||||
INTEGRATION_EVENT = 'integration-event',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
|
import { MentionNode } from '../../../common/helpers/prosemirror/utils';
|
||||||
|
|
||||||
|
|
||||||
export interface IPageBacklinkJob {
|
export interface IPageBacklinkJob {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
@@ -84,14 +84,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
|||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueName.NOTIFICATION_QUEUE,
|
name: QueueName.NOTIFICATION_QUEUE,
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.INTEGRATION_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: { count: 50 },
|
|
||||||
attempts: 3,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
providers: [GeneralQueueProcessor],
|
providers: [GeneralQueueProcessor],
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ async function bootstrap() {
|
|||||||
'/api/sso/google',
|
'/api/sso/google',
|
||||||
'/api/workspace/create',
|
'/api/workspace/create',
|
||||||
'/api/workspace/joined',
|
'/api/workspace/joined',
|
||||||
'/api/integrations/oauth'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
MessageBody,
|
MessageBody,
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
|
OnGatewayInit,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -10,20 +11,30 @@ import { TokenService } from '../core/auth/services/token.service';
|
|||||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||||
import { OnModuleDestroy } from '@nestjs/common';
|
import { OnModuleDestroy } from '@nestjs/common';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
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';
|
import * as cookie from 'cookie';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
})
|
})
|
||||||
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
export class WsGateway
|
||||||
|
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
||||||
|
{
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private wsService: WsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
afterInit(server: Server): void {
|
||||||
|
this.wsService.setServer(server);
|
||||||
|
}
|
||||||
|
|
||||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cookies = cookie.parse(client.handshake.headers.cookie);
|
const cookies = cookie.parse(client.handshake.headers.cookie);
|
||||||
@@ -35,11 +46,13 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
const userId = token.sub;
|
const userId = token.sub;
|
||||||
const workspaceId = token.workspaceId;
|
const workspaceId = token.workspaceId;
|
||||||
|
|
||||||
|
client.data.userId = userId;
|
||||||
|
|
||||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||||
|
|
||||||
const userRoom = `user-${userId}`;
|
const userRoom = getUserRoomName(userId);
|
||||||
const workspaceRoom = `workspace-${workspaceId}`;
|
const workspaceRoom = `workspace-${workspaceId}`;
|
||||||
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
|
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
||||||
|
|
||||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -49,17 +62,9 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
handleMessage(client: Socket, data: any): void {
|
async handleMessage(client: Socket, data: any): Promise<void> {
|
||||||
const spaceEvents = [
|
if (this.wsService.isTreeEvent(data)) {
|
||||||
'updateOne',
|
await this.wsService.handleTreeEvent(client, data);
|
||||||
'addTreeNode',
|
|
||||||
'moveTreeNode',
|
|
||||||
'deleteTreeNode',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (spaceEvents.includes(data?.operation) && data?.spaceId) {
|
|
||||||
const room = this.getSpaceRoomName(data.spaceId);
|
|
||||||
client.broadcast.to(room).emit('message', data);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +87,4 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
this.server.close();
|
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 { WsGateway } from './ws.gateway';
|
||||||
|
import { WsService } from './ws.service';
|
||||||
|
import { WsTreeService } from './ws-tree.service';
|
||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TokenModule],
|
imports: [TokenModule],
|
||||||
providers: [WsGateway],
|
providers: [WsGateway, WsService, WsTreeService],
|
||||||
exports: [WsGateway],
|
exports: [WsGateway, WsService, WsTreeService],
|
||||||
})
|
})
|
||||||
export class WsModule {}
|
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$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -76,8 +76,7 @@
|
|||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.29",
|
"yjs": "^13.6.29"
|
||||||
"zod": "^3.25.76"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "22.5.0",
|
"@nx/js": "22.5.0",
|
||||||
|
|||||||
@@ -25,4 +25,3 @@ export * from "./lib/heading/heading";
|
|||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
export * from "./lib/recreate-transform";
|
export * from "./lib/recreate-transform";
|
||||||
export * from "./lib/integration-link";
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export { IntegrationLink } from "./integration-link";
|
|
||||||
export type {
|
|
||||||
IntegrationLinkOptions,
|
|
||||||
IntegrationLinkAttributes,
|
|
||||||
} from "./integration-link";
|
|
||||||
export {
|
|
||||||
integrationLinkPatterns,
|
|
||||||
matchIntegrationLink,
|
|
||||||
} from "./integration-link-patterns";
|
|
||||||
export type { IntegrationLinkPattern } from "./integration-link-patterns";
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
export type IntegrationLinkPattern = {
|
|
||||||
provider: string;
|
|
||||||
regex: RegExp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const integrationLinkPatterns: IntegrationLinkPattern[] = [
|
|
||||||
// GitHub PR commit (must be before generic PR pattern)
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitHub PR (with optional /checks, /commits, /files sub-pages)
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitHub issue
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitHub commit
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/commits?\/([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitHub file/blob
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/,
|
|
||||||
},
|
|
||||||
// GitHub pulls list
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pulls(?:\/.*)?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitHub releases list
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/releases(?:\/.*)?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitHub issues list
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitHub repo
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/?$/,
|
|
||||||
},
|
|
||||||
// GitLab commit in MR diff (must be before generic MR pattern)
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitLab merge request
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitLab issue
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitLab commit
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitLab issues list
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitLab merge requests list
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitLab project
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_]+)\/?$/,
|
|
||||||
},
|
|
||||||
// Google Docs
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/document\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Sheets
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Slides
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/presentation\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Forms
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/forms\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Drive file
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/drive\.google\.com\/file\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Figma file (design, file, proto, board)
|
|
||||||
{
|
|
||||||
provider: "figma",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/,
|
|
||||||
},
|
|
||||||
// Jira (cloud + server): /browse/KEY-123
|
|
||||||
{
|
|
||||||
provider: "jira",
|
|
||||||
regex: /^https?:\/\/[^\/]+\/browse\/([A-Z][A-Z0-9]+-\d+)/,
|
|
||||||
},
|
|
||||||
// Linear issue: /team/issue/KEY-123(/:title-slug)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)/,
|
|
||||||
},
|
|
||||||
// Linear project: /team/project/:slug(/:tab)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/project\/([^\/]+)/,
|
|
||||||
},
|
|
||||||
// Linear initiative: /team/initiative/:slug(/:tab)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/initiative\/([^\/]+)/,
|
|
||||||
},
|
|
||||||
// Linear view: /team/view/:id(/:tab)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/view\/([^\/]+)/,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function matchIntegrationLink(
|
|
||||||
url: string,
|
|
||||||
): { provider: string; match: RegExpMatchArray } | null {
|
|
||||||
for (const pattern of integrationLinkPatterns) {
|
|
||||||
const match = url.match(pattern.regex);
|
|
||||||
if (match) {
|
|
||||||
return { provider: pattern.provider, match };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
import { sanitizeUrl } from "../utils";
|
|
||||||
|
|
||||||
export interface IntegrationLinkOptions {
|
|
||||||
HTMLAttributes: Record<string, any>;
|
|
||||||
view: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrationLinkAttributes {
|
|
||||||
url: string;
|
|
||||||
provider: string;
|
|
||||||
unfurlData: Record<string, any> | null;
|
|
||||||
status: "pending" | "loaded" | "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
integrationLink: {
|
|
||||||
setIntegrationLink: (
|
|
||||||
attributes: Partial<IntegrationLinkAttributes>,
|
|
||||||
) => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationLink = Node.create<IntegrationLinkOptions>({
|
|
||||||
name: "integrationLink",
|
|
||||||
inline: false,
|
|
||||||
group: "block",
|
|
||||||
isolating: true,
|
|
||||||
atom: true,
|
|
||||||
defining: true,
|
|
||||||
draggable: true,
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
HTMLAttributes: {},
|
|
||||||
view: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
url: {
|
|
||||||
default: "",
|
|
||||||
parseHTML: (element) => {
|
|
||||||
const url = element.getAttribute("data-url");
|
|
||||||
return sanitizeUrl(url);
|
|
||||||
},
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-url": sanitizeUrl(attributes.url),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
default: "",
|
|
||||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-provider": attributes.provider,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
unfurlData: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => {
|
|
||||||
const data = element.getAttribute("data-unfurl");
|
|
||||||
if (!data) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-unfurl": attributes.unfurlData
|
|
||||||
? JSON.stringify(attributes.unfurlData)
|
|
||||||
: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
default: "pending",
|
|
||||||
parseHTML: (element) => element.getAttribute("data-status") ?? "pending",
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-status": attributes.status,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: `div[data-type="${this.name}"]`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
const url = HTMLAttributes["data-url"];
|
|
||||||
const safeUrl = sanitizeUrl(url);
|
|
||||||
|
|
||||||
return [
|
|
||||||
"div",
|
|
||||||
mergeAttributes(
|
|
||||||
{ "data-type": this.name },
|
|
||||||
this.options.HTMLAttributes,
|
|
||||||
HTMLAttributes,
|
|
||||||
),
|
|
||||||
["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl],
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setIntegrationLink:
|
|
||||||
(attrs) =>
|
|
||||||
({ commands }) => {
|
|
||||||
return commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs: {
|
|
||||||
...attrs,
|
|
||||||
url: sanitizeUrl(attrs.url),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
this.editor.isInitialized = true;
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Generated
+51
-3
@@ -211,9 +211,6 @@ importers:
|
|||||||
yjs:
|
yjs:
|
||||||
specifier: ^13.6.29
|
specifier: ^13.6.29
|
||||||
version: 13.6.29
|
version: 13.6.29
|
||||||
zod:
|
|
||||||
specifier: ^3.25.76
|
|
||||||
version: 3.25.76
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@nx/js':
|
'@nx/js':
|
||||||
specifier: 22.5.0
|
specifier: 22.5.0
|
||||||
@@ -477,6 +474,9 @@ importers:
|
|||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.0.0
|
version: 9.0.0
|
||||||
|
'@keyv/redis':
|
||||||
|
specifier: ^5.1.6
|
||||||
|
version: 5.1.6(keyv@5.6.0)
|
||||||
'@langchain/core':
|
'@langchain/core':
|
||||||
specifier: 1.1.18
|
specifier: 1.1.18
|
||||||
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
||||||
@@ -489,6 +489,9 @@ importers:
|
|||||||
'@nestjs/bullmq':
|
'@nestjs/bullmq':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.65.0)
|
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.65.0)
|
||||||
|
'@nestjs/cache-manager':
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.1.11
|
specifier: ^11.1.11
|
||||||
version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -2567,6 +2570,12 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.9':
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||||
|
|
||||||
|
'@keyv/redis@5.1.6':
|
||||||
|
resolution: {integrity: sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
keyv: ^5.6.0
|
||||||
|
|
||||||
'@keyv/serialize@1.1.1':
|
'@keyv/serialize@1.1.1':
|
||||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||||
|
|
||||||
@@ -2770,6 +2779,15 @@ packages:
|
|||||||
'@nestjs/core': ^10.0.0 || ^11.0.0
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
|
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||||
|
|
||||||
|
'@nestjs/cache-manager@3.1.0':
|
||||||
|
resolution: {integrity: sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
cache-manager: '>=6'
|
||||||
|
keyv: '>=5'
|
||||||
|
rxjs: ^7.8.1
|
||||||
|
|
||||||
'@nestjs/cli@11.0.16':
|
'@nestjs/cli@11.0.16':
|
||||||
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
|
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
|
||||||
engines: {node: '>= 20.11'}
|
engines: {node: '>= 20.11'}
|
||||||
@@ -4029,6 +4047,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
|
||||||
|
'@redis/client@5.11.0':
|
||||||
|
resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@node-rs/xxhash': ^1.1.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@node-rs/xxhash':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0':
|
'@remirror/core-constants@3.0.0':
|
||||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
@@ -12870,6 +12897,15 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@keyv/redis@5.1.6(keyv@5.6.0)':
|
||||||
|
dependencies:
|
||||||
|
'@redis/client': 5.11.0
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
hookified: 1.15.1
|
||||||
|
keyv: 5.6.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@node-rs/xxhash'
|
||||||
|
|
||||||
'@keyv/serialize@1.1.1': {}
|
'@keyv/serialize@1.1.1': {}
|
||||||
|
|
||||||
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
|
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
|
||||||
@@ -13062,6 +13098,14 @@ snapshots:
|
|||||||
bullmq: 5.65.0
|
bullmq: 5.65.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@nestjs/cache-manager@3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.13(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
cache-manager: 7.2.8
|
||||||
|
keyv: 5.6.0
|
||||||
|
rxjs: 7.8.2
|
||||||
|
|
||||||
'@nestjs/cli@11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)':
|
'@nestjs/cli@11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
|
||||||
@@ -14368,6 +14412,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
'@redis/client@5.11.0':
|
||||||
|
dependencies:
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0': {}
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.47': {}
|
'@rolldown/pluginutils@1.0.0-beta.47': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user