mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 00:14:10 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee55c8808e | |||
| 91e3a1d015 | |||
| c521917ffe | |||
| f1603925c1 | |||
| bb9b7bad8d | |||
| 2e2585a11e |
@@ -71,7 +71,6 @@
|
|||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
"Failed to create page": "Failed to create page",
|
"Failed to create page": "Failed to create page",
|
||||||
"Failed to delete page": "Failed to delete page",
|
"Failed to delete page": "Failed to delete page",
|
||||||
"Failed to restore page": "Failed to restore page",
|
|
||||||
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
||||||
"Failed to import pages": "Failed to import pages",
|
"Failed to import pages": "Failed to import pages",
|
||||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||||
@@ -362,8 +361,6 @@
|
|||||||
"Create block quote.": "Create block quote.",
|
"Create block quote.": "Create block quote.",
|
||||||
"Insert code snippet.": "Insert code snippet.",
|
"Insert code snippet.": "Insert code snippet.",
|
||||||
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
||||||
"Page break": "Page break",
|
|
||||||
"Insert a page break for printing.": "Insert a page break for printing.",
|
|
||||||
"Upload any image from your device.": "Upload any image from your device.",
|
"Upload any image from your device.": "Upload any image from your device.",
|
||||||
"Upload any video from your device.": "Upload any video from your device.",
|
"Upload any video from your device.": "Upload any video from your device.",
|
||||||
"Upload any audio from your device.": "Upload any audio from your device.",
|
"Upload any audio from your device.": "Upload any audio from your device.",
|
||||||
@@ -582,8 +579,6 @@
|
|||||||
"Move to trash": "Move to trash",
|
"Move to trash": "Move to trash",
|
||||||
"Move this page to trash?": "Move this page to trash?",
|
"Move this page to trash?": "Move this page to trash?",
|
||||||
"Restore page": "Restore page",
|
"Restore page": "Restore page",
|
||||||
"Permanently delete": "Permanently delete",
|
|
||||||
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
|
|
||||||
"Page moved to trash": "Page moved to trash",
|
"Page moved to trash": "Page moved to trash",
|
||||||
"Page restored successfully": "Page restored successfully",
|
"Page restored successfully": "Page restored successfully",
|
||||||
"Deleted by": "Deleted by",
|
"Deleted by": "Deleted by",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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 AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||||
import Webhooks from "@/ee/webhook/pages/webhooks.tsx";
|
|
||||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||||
import TemplateList from "@/ee/template/pages/template-list";
|
import TemplateList from "@/ee/template/pages/template-list";
|
||||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||||
@@ -125,7 +124,6 @@ export default function App() {
|
|||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
<Route path={"webhooks"} element={<Webhooks />} />
|
|
||||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core";
|
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||||
import { IconX } from "@tabler/icons-react";
|
|
||||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -12,10 +11,9 @@ import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
|||||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||||
|
|
||||||
export default function Aside() {
|
export default function Aside() {
|
||||||
const [{ tab }, setAsideState] = useAtom(asideStateAtom);
|
const [{ tab }] = useAtom(asideStateAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let component: ReactNode;
|
let component: ReactNode;
|
||||||
@@ -47,19 +45,9 @@ export default function Aside() {
|
|||||||
{component && (
|
{component && (
|
||||||
<>
|
<>
|
||||||
{tab !== "chat" && (
|
{tab !== "chat" && (
|
||||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
<Text mb="md" fw={500}>
|
||||||
<Text fw={500}>{t(title)}</Text>
|
{t(title)}
|
||||||
<Tooltip label={t("Close")} withArrow>
|
</Text>
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={closeAside}
|
|
||||||
aria-label={t("Close")}
|
|
||||||
>
|
|
||||||
<IconX size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "comments" || tab === "chat" ? (
|
{tab === "comments" || tab === "chat" ? (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { getApiKeys } from "@/ee/api-key";
|
|||||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||||
import { getWebhooks } from "@/ee/webhook/services/webhook-service";
|
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
@@ -107,11 +106,3 @@ export const prefetchScimTokens = () => {
|
|||||||
queryFn: () => getScimTokens({}),
|
queryFn: () => getScimTokens({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchWebhooks = () => {
|
|
||||||
const params = { limit: 50 };
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["webhook-list", params],
|
|
||||||
queryFn: () => getWebhooks(params),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconWebhook,
|
|
||||||
} 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";
|
||||||
@@ -39,7 +38,6 @@ import {
|
|||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
prefetchAuditLogs,
|
prefetchAuditLogs,
|
||||||
prefetchVerifiedPages,
|
prefetchVerifiedPages,
|
||||||
prefetchWebhooks,
|
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -127,13 +125,6 @@ const groupedData: DataGroup[] = [
|
|||||||
role: "owner",
|
role: "owner",
|
||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Webhooks",
|
|
||||||
icon: IconWebhook,
|
|
||||||
path: "/settings/webhooks",
|
|
||||||
feature: Feature.WEBHOOKS,
|
|
||||||
role: "admin",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -231,9 +222,6 @@ export default function SettingsSidebar() {
|
|||||||
case "Audit log":
|
case "Audit log":
|
||||||
prefetchHandler = prefetchAuditLogs;
|
prefetchHandler = prefetchAuditLogs;
|
||||||
break;
|
break;
|
||||||
case "Webhooks":
|
|
||||||
prefetchHandler = prefetchWebhooks;
|
|
||||||
break;
|
|
||||||
case "Verified pages":
|
case "Verified pages":
|
||||||
prefetchHandler = prefetchVerifiedPages;
|
prefetchHandler = prefetchVerifiedPages;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -19,5 +19,4 @@ export const Feature = {
|
|||||||
SHARING_CONTROLS: 'sharing:controls',
|
SHARING_CONTROLS: 'sharing:controls',
|
||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
VIEWER_COMMENTS: 'comment:viewer',
|
VIEWER_COMMENTS: 'comment:viewer',
|
||||||
WEBHOOKS: 'webhooks',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
MultiSelect,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useCreateWebhookMutation } from "@/ee/webhook/queries/webhook-query";
|
|
||||||
import {
|
|
||||||
EVENT_GROUPS,
|
|
||||||
multiSelectData,
|
|
||||||
} from "@/ee/webhook/lib/webhook-event-labels";
|
|
||||||
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
|
|
||||||
|
|
||||||
interface CreateWebhookModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: (signingSecret: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedEvents: WebhookEvent[] = EVENT_GROUPS.flatMap((g) => g.events);
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
|
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.min(1, "URL is required")
|
|
||||||
.refine(
|
|
||||||
(value) => /^https?:\/\//i.test(value),
|
|
||||||
"URL must start with http:// or https://",
|
|
||||||
),
|
|
||||||
subscribedEvents: z
|
|
||||||
.array(z.enum(allowedEvents as [WebhookEvent, ...WebhookEvent[]]))
|
|
||||||
.min(1, "Select at least one event"),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export function CreateWebhookModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}: CreateWebhookModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const createWebhookMutation = useCreateWebhookMutation();
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
url: "",
|
|
||||||
subscribedEvents: [],
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (values: FormValues) => {
|
|
||||||
try {
|
|
||||||
const result = await createWebhookMutation.mutateAsync({
|
|
||||||
name: values.name,
|
|
||||||
url: values.url,
|
|
||||||
subscribedEvents: values.subscribedEvents,
|
|
||||||
isActive: values.isActive,
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
onSuccess(result.signingSecret);
|
|
||||||
} catch (_err) {
|
|
||||||
// notification handled inside mutation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Create webhook")}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label={t("Name")}
|
|
||||||
placeholder={t("e.g. Production alerts")}
|
|
||||||
required
|
|
||||||
data-autofocus
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label={t("URL")}
|
|
||||||
placeholder="https://example.com/webhook"
|
|
||||||
required
|
|
||||||
{...form.getInputProps("url")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MultiSelect
|
|
||||||
label={t("Events")}
|
|
||||||
placeholder={t("Select events to subscribe to")}
|
|
||||||
data={multiSelectData()}
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
required
|
|
||||||
{...form.getInputProps("subscribedEvents")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={t("Active")}
|
|
||||||
description={t("Deliveries fire only when the webhook is active")}
|
|
||||||
checked={form.values.isActive}
|
|
||||||
onChange={(event) =>
|
|
||||||
form.setFieldValue("isActive", event.currentTarget.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={handleClose}>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={createWebhookMutation.isPending}>
|
|
||||||
{t("Create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
import { Fragment, useState } from "react";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Collapse,
|
|
||||||
Drawer,
|
|
||||||
Group,
|
|
||||||
ScrollArea,
|
|
||||||
Skeleton,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconRefresh,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
useRedeliverMutation,
|
|
||||||
useWebhookDeliveries,
|
|
||||||
} from "@/ee/webhook/queries/webhook-query";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import type {
|
|
||||||
IWebhookDelivery,
|
|
||||||
WebhookDeliveryStatus,
|
|
||||||
} from "@/ee/webhook/types/webhook.types";
|
|
||||||
|
|
||||||
interface DeliveryDrawerProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
webhookId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusColor(status: WebhookDeliveryStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "success":
|
|
||||||
return "green";
|
|
||||||
case "failed":
|
|
||||||
return "red";
|
|
||||||
case "pending":
|
|
||||||
return "yellow";
|
|
||||||
case "skipped_cooldown":
|
|
||||||
case "skipped_inflight":
|
|
||||||
case "skipped_disabled":
|
|
||||||
default:
|
|
||||||
return "gray";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: WebhookDeliveryStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "skipped_cooldown":
|
|
||||||
return "skipped (cooldown)";
|
|
||||||
case "skipped_inflight":
|
|
||||||
return "skipped (in-flight)";
|
|
||||||
case "skipped_disabled":
|
|
||||||
return "skipped (disabled)";
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canRedeliver(status: WebhookDeliveryStatus): boolean {
|
|
||||||
return (
|
|
||||||
status === "failed" ||
|
|
||||||
status === "skipped_cooldown" ||
|
|
||||||
status === "skipped_inflight" ||
|
|
||||||
status === "skipped_disabled"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeliveryRow({
|
|
||||||
delivery,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
onRedeliver,
|
|
||||||
isRedelivering,
|
|
||||||
}: {
|
|
||||||
delivery: IWebhookDelivery;
|
|
||||||
expanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onRedeliver: () => void;
|
|
||||||
isRedelivering: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<Table.Tr style={{ cursor: "pointer" }} onClick={onToggle}>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
{expanded ? (
|
|
||||||
<IconChevronDown
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconChevronRight
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{delivery.event}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge color={statusColor(delivery.status)} variant="light">
|
|
||||||
{statusLabel(delivery.status)}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">
|
|
||||||
{delivery.httpStatus ?? "—"}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">
|
|
||||||
{delivery.durationMs != null ? `${delivery.durationMs} ms` : "—"}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formattedDate(new Date(delivery.createdAt))}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
|
||||||
{canRedeliver(delivery.status) ? (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconRefresh size={12} />}
|
|
||||||
onClick={onRedeliver}
|
|
||||||
loading={isRedelivering}
|
|
||||||
>
|
|
||||||
{t("Redeliver")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={6} p={0} style={{ border: "none" }}>
|
|
||||||
<Collapse in={expanded}>
|
|
||||||
<Box
|
|
||||||
px="md"
|
|
||||||
py="sm"
|
|
||||||
style={{ background: "var(--mantine-color-gray-light)" }}
|
|
||||||
>
|
|
||||||
<Text fz="xs" fw={600} mb={4}>
|
|
||||||
{t("Payload")}
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
component="pre"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
margin: 0,
|
|
||||||
padding: 8,
|
|
||||||
background: "var(--mantine-color-body)",
|
|
||||||
borderRadius: 4,
|
|
||||||
overflowX: "auto",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(delivery.payload, null, 2)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{delivery.responseBody && (
|
|
||||||
<>
|
|
||||||
<Text fz="xs" fw={600} mt="sm" mb={4}>
|
|
||||||
{t("Response body")}
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
component="pre"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
margin: 0,
|
|
||||||
padding: 8,
|
|
||||||
background: "var(--mantine-color-body)",
|
|
||||||
borderRadius: 4,
|
|
||||||
overflowX: "auto",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{delivery.responseBody}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{delivery.errorMessage && (
|
|
||||||
<>
|
|
||||||
<Text fz="xs" fw={600} mt="sm" mb={4} c="red">
|
|
||||||
{t("Error")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="red">
|
|
||||||
{delivery.errorMessage}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeliveryDrawer({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
webhookId,
|
|
||||||
}: DeliveryDrawerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading } = useWebhookDeliveries(opened ? webhookId : null);
|
|
||||||
const redeliverMutation = useRedeliverMutation(webhookId ?? undefined);
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
||||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRedeliver = async (deliveryId: string) => {
|
|
||||||
setPendingId(deliveryId);
|
|
||||||
try {
|
|
||||||
await redeliverMutation.mutateAsync({ deliveryId });
|
|
||||||
} catch (_err) {
|
|
||||||
// notification handled inside mutation
|
|
||||||
} finally {
|
|
||||||
setPendingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Recent deliveries")}
|
|
||||||
position="right"
|
|
||||||
size="xl"
|
|
||||||
>
|
|
||||||
<ScrollArea h="calc(100vh - 80px)">
|
|
||||||
<Table verticalSpacing="xs" striped={false}>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Event")}</Table.Th>
|
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
|
||||||
<Table.Th>{t("HTTP")}</Table.Th>
|
|
||||||
<Table.Th>{t("Duration")}</Table.Th>
|
|
||||||
<Table.Th>{t("Timestamp")}</Table.Th>
|
|
||||||
<Table.Th aria-label={t("Action")} />
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Table.Tr key={i}>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={70} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={40} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={60} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={140} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={70} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : data && data.length > 0 ? (
|
|
||||||
data.map((delivery) => (
|
|
||||||
<DeliveryRow
|
|
||||||
key={delivery.id}
|
|
||||||
delivery={delivery}
|
|
||||||
expanded={expanded.has(delivery.id)}
|
|
||||||
onToggle={() => toggle(delivery.id)}
|
|
||||||
onRedeliver={() => handleRedeliver(delivery.id)}
|
|
||||||
isRedelivering={
|
|
||||||
pendingId === delivery.id && redeliverMutation.isPending
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={6} text={t("No deliveries yet")} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
MultiSelect,
|
|
||||||
PasswordInput,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
useRotateSecretMutation,
|
|
||||||
useSendTestMutation,
|
|
||||||
useUpdateWebhookMutation,
|
|
||||||
useWebhook,
|
|
||||||
} from "@/ee/webhook/queries/webhook-query";
|
|
||||||
import {
|
|
||||||
EVENT_GROUPS,
|
|
||||||
multiSelectData,
|
|
||||||
} from "@/ee/webhook/lib/webhook-event-labels";
|
|
||||||
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
|
|
||||||
import { WebhookSecretModal } from "@/ee/webhook/components/webhook-secret-modal";
|
|
||||||
|
|
||||||
interface EditWebhookModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
webhookId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedEvents: WebhookEvent[] = EVENT_GROUPS.flatMap((g) => g.events);
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
|
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.min(1, "URL is required")
|
|
||||||
.refine(
|
|
||||||
(value) => /^https?:\/\//i.test(value),
|
|
||||||
"URL must start with http:// or https://",
|
|
||||||
),
|
|
||||||
subscribedEvents: z
|
|
||||||
.array(z.enum(allowedEvents as [WebhookEvent, ...WebhookEvent[]]))
|
|
||||||
.min(1, "Select at least one event"),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export function EditWebhookModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
webhookId,
|
|
||||||
}: EditWebhookModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: webhook, isLoading } = useWebhook(opened ? webhookId : null);
|
|
||||||
const updateMutation = useUpdateWebhookMutation();
|
|
||||||
const rotateMutation = useRotateSecretMutation();
|
|
||||||
const sendTestMutation = useSendTestMutation();
|
|
||||||
|
|
||||||
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
url: "",
|
|
||||||
subscribedEvents: [],
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (opened && webhook) {
|
|
||||||
form.setValues({
|
|
||||||
name: webhook.name,
|
|
||||||
url: webhook.url,
|
|
||||||
subscribedEvents: webhook.subscribedEvents,
|
|
||||||
isActive: webhook.isActive,
|
|
||||||
});
|
|
||||||
form.resetDirty();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [opened, webhook?.id]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (values: FormValues) => {
|
|
||||||
if (!webhookId) return;
|
|
||||||
try {
|
|
||||||
await updateMutation.mutateAsync({
|
|
||||||
webhookId,
|
|
||||||
name: values.name,
|
|
||||||
url: values.url,
|
|
||||||
subscribedEvents: values.subscribedEvents,
|
|
||||||
isActive: values.isActive,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
} catch (_err) {
|
|
||||||
// notification handled inside mutation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRotate = async () => {
|
|
||||||
if (!webhookId) return;
|
|
||||||
try {
|
|
||||||
const result = await rotateMutation.mutateAsync({ webhookId });
|
|
||||||
setRevealedSecret(result.signingSecret);
|
|
||||||
} catch (_err) {
|
|
||||||
// notification handled inside mutation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendTest = async () => {
|
|
||||||
if (!webhookId) return;
|
|
||||||
try {
|
|
||||||
await sendTestMutation.mutateAsync({ webhookId });
|
|
||||||
} catch (_err) {
|
|
||||||
// notification handled inside mutation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Edit webhook")}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label={t("Name")}
|
|
||||||
placeholder={t("e.g. Production alerts")}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label={t("URL")}
|
|
||||||
placeholder="https://example.com/webhook"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
{...form.getInputProps("url")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MultiSelect
|
|
||||||
label={t("Events")}
|
|
||||||
placeholder={t("Select events to subscribe to")}
|
|
||||||
data={multiSelectData()}
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
{...form.getInputProps("subscribedEvents")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={t("Active")}
|
|
||||||
description={t(
|
|
||||||
"Deliveries fire only when the webhook is active",
|
|
||||||
)}
|
|
||||||
checked={form.values.isActive}
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(event) =>
|
|
||||||
form.setFieldValue("isActive", event.currentTarget.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider my="xs" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb="xs">
|
|
||||||
{t("Signing secret")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
|
||||||
<PasswordInput
|
|
||||||
value="dm_wh_••••••••••••••••••••••••••••••••"
|
|
||||||
readOnly
|
|
||||||
visible={false}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleRotate}
|
|
||||||
loading={rotateMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Rotate")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
|
||||||
{t(
|
|
||||||
"Rotating generates a new signing secret. The previous secret stops working immediately.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider my="xs" />
|
|
||||||
|
|
||||||
<Group justify="space-between" mt="md">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleSendTest}
|
|
||||||
loading={sendTestMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Send test event")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<Button variant="default" onClick={handleClose}>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={updateMutation.isPending}>
|
|
||||||
{t("Save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<WebhookSecretModal
|
|
||||||
opened={!!revealedSecret}
|
|
||||||
onClose={() => setRevealedSecret(null)}
|
|
||||||
secret={revealedSecret}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Code,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import CopyTextButton from "@/components/common/copy";
|
|
||||||
|
|
||||||
interface WebhookSecretModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
secret: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebhookSecretModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
secret,
|
|
||||||
}: WebhookSecretModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!secret) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Save this signing secret")}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertTriangle size={16} />}
|
|
||||||
title={t("Important")}
|
|
||||||
color="red"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"We won't show it again. Copy it now and store it somewhere safe. You can rotate it later if needed.",
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb="xs">
|
|
||||||
{t("Signing secret")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap" align="center">
|
|
||||||
<Code
|
|
||||||
block
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
wordBreak: "break-all",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{secret}
|
|
||||||
</Code>
|
|
||||||
<CopyTextButton text={secret} />
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Use this secret to verify the X-Docmost-Signature header on incoming webhook deliveries.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button fullWidth onClick={onClose} mt="md">
|
|
||||||
{t("I've saved my signing secret")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Anchor,
|
|
||||||
Badge,
|
|
||||||
Group,
|
|
||||||
Menu,
|
|
||||||
Skeleton,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import {
|
|
||||||
IconDots,
|
|
||||||
IconEdit,
|
|
||||||
IconList,
|
|
||||||
IconSend,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
useDeleteWebhookMutation,
|
|
||||||
useSendTestMutation,
|
|
||||||
} from "@/ee/webhook/queries/webhook-query";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import type { IWebhook } from "@/ee/webhook/types/webhook.types";
|
|
||||||
|
|
||||||
interface WebhookTableProps {
|
|
||||||
webhooks: IWebhook[] | undefined;
|
|
||||||
isLoading: boolean;
|
|
||||||
onEdit: (webhook: IWebhook) => void;
|
|
||||||
onViewDeliveries: (webhook: IWebhook) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(value: string, max: number): string {
|
|
||||||
if (value.length <= max) return value;
|
|
||||||
return value.slice(0, max) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Table.Tr key={i}>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={140} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={220} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={70} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={70} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={24} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebhookTable({
|
|
||||||
webhooks,
|
|
||||||
isLoading,
|
|
||||||
onEdit,
|
|
||||||
onViewDeliveries,
|
|
||||||
}: WebhookTableProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const deleteMutation = useDeleteWebhookMutation();
|
|
||||||
const sendTestMutation = useSendTestMutation();
|
|
||||||
|
|
||||||
const handleDelete = (webhook: IWebhook) => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Delete webhook"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Are you sure you want to delete the webhook {{name}}? This action cannot be undone.",
|
|
||||||
{ name: webhook.name },
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: () => {
|
|
||||||
deleteMutation.mutate({ webhookId: webhook.id });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.ScrollContainer minWidth={760}>
|
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Name")}</Table.Th>
|
|
||||||
<Table.Th>{t("URL")}</Table.Th>
|
|
||||||
<Table.Th>{t("Events")}</Table.Th>
|
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
|
||||||
<Table.Th>{t("Created")}</Table.Th>
|
|
||||||
<Table.Th aria-label={t("Action")} />
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton />
|
|
||||||
) : webhooks && webhooks.length > 0 ? (
|
|
||||||
webhooks.map((webhook) => (
|
|
||||||
<Table.Tr key={webhook.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onEdit(webhook)}
|
|
||||||
underline="never"
|
|
||||||
style={{ color: "var(--mantine-color-text)" }}
|
|
||||||
>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{webhook.name}
|
|
||||||
</Text>
|
|
||||||
</Anchor>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Tooltip label={webhook.url} withArrow position="top-start">
|
|
||||||
<Text fz="sm" c="dimmed" style={{ fontFamily: "monospace" }}>
|
|
||||||
{truncate(webhook.url, 60)}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Tooltip
|
|
||||||
label={webhook.subscribedEvents.join(", ")}
|
|
||||||
withArrow
|
|
||||||
multiline
|
|
||||||
w={280}
|
|
||||||
>
|
|
||||||
<Badge variant="light" color="blue">
|
|
||||||
{t("{{count}} events", {
|
|
||||||
count: webhook.subscribedEvents.length,
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
color={webhook.isActive ? "green" : "gray"}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{webhook.isActive ? t("Active") : t("Inactive")}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formattedDate(new Date(webhook.createdAt))}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Menu position="bottom-end" withinPortal>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label={t("Webhook menu")}
|
|
||||||
>
|
|
||||||
<IconDots size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconEdit size={16} />}
|
|
||||||
onClick={() => onEdit(webhook)}
|
|
||||||
>
|
|
||||||
{t("Edit")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconSend size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
sendTestMutation.mutate({ webhookId: webhook.id })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Send test event")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconList size={16} />}
|
|
||||||
onClick={() => onViewDeliveries(webhook)}
|
|
||||||
>
|
|
||||||
{t("View deliveries")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
color="red"
|
|
||||||
onClick={() => handleDelete(webhook)}
|
|
||||||
>
|
|
||||||
{t("Delete")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults
|
|
||||||
colSpan={6}
|
|
||||||
text={t("No webhooks yet. Add one to start receiving events.")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
|
|
||||||
|
|
||||||
export const EVENT_GROUPS: { group: string; events: WebhookEvent[] }[] = [
|
|
||||||
{
|
|
||||||
group: "Pages",
|
|
||||||
events: [
|
|
||||||
"page.created",
|
|
||||||
"page.updated",
|
|
||||||
"page.moved",
|
|
||||||
"page.deleted",
|
|
||||||
"page.restored",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Comments",
|
|
||||||
events: [
|
|
||||||
"comment.created",
|
|
||||||
"comment.updated",
|
|
||||||
"comment.deleted",
|
|
||||||
"comment.resolved",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Spaces",
|
|
||||||
events: ["space.created", "space.updated", "space.deleted"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Attachments",
|
|
||||||
events: ["attachment.uploaded"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Members",
|
|
||||||
events: ["user.created", "user.deactivated"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const eventLabel = (event: string): string => event;
|
|
||||||
|
|
||||||
export const multiSelectData = () =>
|
|
||||||
EVENT_GROUPS.map(({ group, events }) => ({
|
|
||||||
group,
|
|
||||||
items: events.map((e) => ({ value: e, label: e })),
|
|
||||||
}));
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Button, Group, Space } from "@mantine/core";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import Paginate from "@/components/common/paginate";
|
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import useUserRole from "@/hooks/use-user-role";
|
|
||||||
import { useWebhooks } from "@/ee/webhook/queries/webhook-query";
|
|
||||||
import { WebhookTable } from "@/ee/webhook/components/webhook-table";
|
|
||||||
import { CreateWebhookModal } from "@/ee/webhook/components/create-webhook-modal";
|
|
||||||
import { EditWebhookModal } from "@/ee/webhook/components/edit-webhook-modal";
|
|
||||||
import { WebhookSecretModal } from "@/ee/webhook/components/webhook-secret-modal";
|
|
||||||
import { DeliveryDrawer } from "@/ee/webhook/components/delivery-drawer";
|
|
||||||
import type { IWebhook, IListWebhooksParams } from "@/ee/webhook/types/webhook.types";
|
|
||||||
|
|
||||||
export default function Webhooks() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
|
||||||
|
|
||||||
const [createOpened, setCreateOpened] = useState(false);
|
|
||||||
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
|
|
||||||
const [editingWebhookId, setEditingWebhookId] = useState<string | null>(null);
|
|
||||||
const [deliveryWebhookId, setDeliveryWebhookId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const params: IListWebhooksParams = useMemo(
|
|
||||||
() => ({ cursor, limit: 50 }),
|
|
||||||
[cursor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading } = useWebhooks(params);
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (webhook: IWebhook) => {
|
|
||||||
setEditingWebhookId(webhook.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewDeliveries = (webhook: IWebhook) => {
|
|
||||||
setDeliveryWebhookId(webhook.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Webhooks")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("Webhooks")} />
|
|
||||||
|
|
||||||
<Group justify="flex-end" mb="md">
|
|
||||||
<Button onClick={() => setCreateOpened(true)}>
|
|
||||||
{t("Add webhook")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<WebhookTable
|
|
||||||
webhooks={data?.items}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onViewDeliveries={handleViewDeliveries}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Space h="md" />
|
|
||||||
|
|
||||||
{data?.items && data.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
|
||||||
onPrev={goPrev}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateWebhookModal
|
|
||||||
opened={createOpened}
|
|
||||||
onClose={() => setCreateOpened(false)}
|
|
||||||
onSuccess={(signingSecret) => setRevealedSecret(signingSecret)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WebhookSecretModal
|
|
||||||
opened={!!revealedSecret}
|
|
||||||
onClose={() => setRevealedSecret(null)}
|
|
||||||
secret={revealedSecret}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditWebhookModal
|
|
||||||
opened={!!editingWebhookId}
|
|
||||||
onClose={() => setEditingWebhookId(null)}
|
|
||||||
webhookId={editingWebhookId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeliveryDrawer
|
|
||||||
opened={!!deliveryWebhookId}
|
|
||||||
onClose={() => setDeliveryWebhookId(null)}
|
|
||||||
webhookId={deliveryWebhookId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
createWebhook,
|
|
||||||
deleteWebhook,
|
|
||||||
getWebhook,
|
|
||||||
getWebhookDeliveries,
|
|
||||||
getWebhooks,
|
|
||||||
redeliverWebhook,
|
|
||||||
rotateWebhookSecret,
|
|
||||||
sendWebhookTest,
|
|
||||||
updateWebhook,
|
|
||||||
} from "@/ee/webhook/services/webhook-service";
|
|
||||||
import {
|
|
||||||
ICreateWebhook,
|
|
||||||
IListWebhooksParams,
|
|
||||||
IUpdateWebhook,
|
|
||||||
IWebhook,
|
|
||||||
IWebhookCreated,
|
|
||||||
IWebhookDelivery,
|
|
||||||
} from "@/ee/webhook/types/webhook.types";
|
|
||||||
import { IPagination } from "@/lib/types";
|
|
||||||
|
|
||||||
const WEBHOOK_LIST_KEY = "webhook-list";
|
|
||||||
const WEBHOOK_INFO_KEY = "webhook-info";
|
|
||||||
const WEBHOOK_DELIVERIES_KEY = "webhook-deliveries";
|
|
||||||
|
|
||||||
export function useWebhooks(
|
|
||||||
params?: IListWebhooksParams,
|
|
||||||
): UseQueryResult<IPagination<IWebhook>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [WEBHOOK_LIST_KEY, params],
|
|
||||||
queryFn: () => getWebhooks(params),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWebhook(
|
|
||||||
webhookId: string | null | undefined,
|
|
||||||
): UseQueryResult<IWebhook, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [WEBHOOK_INFO_KEY, webhookId],
|
|
||||||
queryFn: () => getWebhook(webhookId as string),
|
|
||||||
enabled: !!webhookId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWebhookDeliveries(
|
|
||||||
webhookId: string | null | undefined,
|
|
||||||
): UseQueryResult<IWebhookDelivery[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId],
|
|
||||||
queryFn: () => getWebhookDeliveries(webhookId as string),
|
|
||||||
enabled: !!webhookId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateLists(queryClient: ReturnType<typeof useQueryClient>) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) => item.queryKey[0] === WEBHOOK_LIST_KEY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWebhookMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<IWebhookCreated, Error, ICreateWebhook>({
|
|
||||||
mutationFn: (data) => createWebhook(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Webhook created") });
|
|
||||||
invalidateLists(queryClient);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateWebhookMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<IWebhook, Error, IUpdateWebhook>({
|
|
||||||
mutationFn: (data) => updateWebhook(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
notifications.show({ message: t("Webhook updated") });
|
|
||||||
invalidateLists(queryClient);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [WEBHOOK_INFO_KEY, data.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWebhookMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<{ success: boolean }, Error, { webhookId: string }>({
|
|
||||||
mutationFn: ({ webhookId }) => deleteWebhook(webhookId),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Webhook deleted") });
|
|
||||||
invalidateLists(queryClient);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRotateSecretMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
{ signingSecret: string },
|
|
||||||
Error,
|
|
||||||
{ webhookId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ webhookId }) => rotateWebhookSecret(webhookId),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
notifications.show({ message: t("Signing secret rotated") });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [WEBHOOK_INFO_KEY, variables.webhookId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSendTestMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<{ deliveryId: string }, Error, { webhookId: string }>({
|
|
||||||
mutationFn: ({ webhookId }) => sendWebhookTest(webhookId),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
notifications.show({ message: t("Test event sent") });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [WEBHOOK_DELIVERIES_KEY, variables.webhookId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRedeliverMutation(webhookId?: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<{ deliveryId: string }, Error, { deliveryId: string }>({
|
|
||||||
mutationFn: ({ deliveryId }) => redeliverWebhook(deliveryId),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Redelivery queued") });
|
|
||||||
if (webhookId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) => item.queryKey[0] === WEBHOOK_DELIVERIES_KEY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { IPagination } from "@/lib/types";
|
|
||||||
import {
|
|
||||||
ICreateWebhook,
|
|
||||||
IListWebhooksParams,
|
|
||||||
IUpdateWebhook,
|
|
||||||
IWebhook,
|
|
||||||
IWebhookCreated,
|
|
||||||
IWebhookDelivery,
|
|
||||||
} from "@/ee/webhook/types/webhook.types";
|
|
||||||
|
|
||||||
export async function getWebhooks(
|
|
||||||
params?: IListWebhooksParams,
|
|
||||||
): Promise<IPagination<IWebhook>> {
|
|
||||||
const req = await api.post("/webhooks", { ...params });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWebhook(webhookId: string): Promise<IWebhook> {
|
|
||||||
const req = await api.post<IWebhook>("/webhooks/info", { webhookId });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWebhook(
|
|
||||||
data: ICreateWebhook,
|
|
||||||
): Promise<IWebhookCreated> {
|
|
||||||
const req = await api.post<IWebhookCreated>("/webhooks/create", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWebhook(data: IUpdateWebhook): Promise<IWebhook> {
|
|
||||||
const req = await api.post<IWebhook>("/webhooks/update", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWebhook(
|
|
||||||
webhookId: string,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const req = await api.post("/webhooks/delete", { webhookId });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rotateWebhookSecret(
|
|
||||||
webhookId: string,
|
|
||||||
): Promise<{ signingSecret: string }> {
|
|
||||||
const req = await api.post<{ signingSecret: string }>(
|
|
||||||
"/webhooks/rotate-secret",
|
|
||||||
{ webhookId },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendWebhookTest(
|
|
||||||
webhookId: string,
|
|
||||||
): Promise<{ deliveryId: string }> {
|
|
||||||
const req = await api.post<{ deliveryId: string }>("/webhooks/test", {
|
|
||||||
webhookId,
|
|
||||||
});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWebhookDeliveries(
|
|
||||||
webhookId: string,
|
|
||||||
limit?: number,
|
|
||||||
): Promise<IWebhookDelivery[]> {
|
|
||||||
const req = await api.post<IWebhookDelivery[]>("/webhooks/deliveries", {
|
|
||||||
webhookId,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function redeliverWebhook(
|
|
||||||
deliveryId: string,
|
|
||||||
): Promise<{ deliveryId: string }> {
|
|
||||||
const req = await api.post<{ deliveryId: string }>(
|
|
||||||
"/webhooks/deliveries/redeliver",
|
|
||||||
{ deliveryId },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
export type WebhookEvent =
|
|
||||||
| "page.created"
|
|
||||||
| "page.updated"
|
|
||||||
| "page.moved"
|
|
||||||
| "page.deleted"
|
|
||||||
| "page.restored"
|
|
||||||
| "comment.created"
|
|
||||||
| "comment.updated"
|
|
||||||
| "comment.deleted"
|
|
||||||
| "comment.resolved"
|
|
||||||
| "space.created"
|
|
||||||
| "space.updated"
|
|
||||||
| "space.deleted"
|
|
||||||
| "attachment.uploaded"
|
|
||||||
| "user.created"
|
|
||||||
| "user.deactivated";
|
|
||||||
|
|
||||||
export type WebhookDeliveryStatus =
|
|
||||||
| "pending"
|
|
||||||
| "success"
|
|
||||||
| "failed"
|
|
||||||
| "skipped_cooldown"
|
|
||||||
| "skipped_disabled"
|
|
||||||
| "skipped_inflight";
|
|
||||||
|
|
||||||
export interface IWebhook {
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
subscribedEvents: WebhookEvent[];
|
|
||||||
isActive: boolean;
|
|
||||||
consecutiveFailureCount: number;
|
|
||||||
disabledAt: string | null;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IWebhookCreated extends IWebhook {
|
|
||||||
signingSecret: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IWebhookDelivery {
|
|
||||||
id: string;
|
|
||||||
webhookId: string;
|
|
||||||
event: string;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
status: WebhookDeliveryStatus;
|
|
||||||
httpStatus: number | null;
|
|
||||||
responseBody: string | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
attemptCount: number;
|
|
||||||
durationMs: number | null;
|
|
||||||
deliveredAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ICreateWebhook {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
subscribedEvents: WebhookEvent[];
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IUpdateWebhook {
|
|
||||||
webhookId: string;
|
|
||||||
name?: string;
|
|
||||||
url?: string;
|
|
||||||
subscribedEvents?: WebhookEvent[];
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IListWebhooksParams {
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
@@ -13,7 +12,3 @@ export const yjsConnectionStatusAtom = atom<string>("");
|
|||||||
export const showAiMenuAtom = atom(false);
|
export const showAiMenuAtom = atom(false);
|
||||||
|
|
||||||
export const showLinkMenuAtom = atom(false);
|
export const showLinkMenuAtom = atom(false);
|
||||||
|
|
||||||
// Current page's edit mode — initialized from the user's saved preference on
|
|
||||||
// first load, can be toggled locally without persisting to the server.
|
|
||||||
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -47,7 +46,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "audio";
|
const predicate = (node: PMNode) => node.type.name === "audio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
IconMoodSmile,
|
IconMoodSmile,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
IconCopy,
|
IconCopy,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
import { isTextSelected } from "@docmost/editor-ext";
|
||||||
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state || !isEditorReady(editor)) return false;
|
if (!state) return false;
|
||||||
if (!editor.isActive("columns")) return false;
|
if (!editor.isActive("columns")) return false;
|
||||||
if (isTextSelected(editor)) return false;
|
if (isTextSelected(editor)) return false;
|
||||||
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
||||||
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "columns";
|
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -82,7 +81,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
|
||||||
|
|
||||||
const ExcalidrawMenu = lazy(
|
|
||||||
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ExcalidrawMenuLazy(props: EditorMenuProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ExcalidrawMenu {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -95,7 +94,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { NodeViewProps } from "@tiptap/react";
|
|
||||||
|
|
||||||
const ExcalidrawView = lazy(
|
|
||||||
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ExcalidrawViewLazy(props: NodeViewProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ExcalidrawView {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconH2,
|
IconH2,
|
||||||
IconH3,
|
IconH3,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconPageBreak,
|
|
||||||
IconTypography,
|
IconTypography,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -103,12 +102,6 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
|
|||||||
>
|
>
|
||||||
{t("Divider")}
|
{t("Divider")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconPageBreak size={16} />}
|
|
||||||
onClick={() => editor.chain().focus().setPageBreak().run()}
|
|
||||||
>
|
|
||||||
{t("Page break")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -57,7 +56,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "image";
|
const predicate = (node: PMNode) => node.type.name === "image";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -38,8 +37,9 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state || !isEditorReady(editor)) return false;
|
if (!state || !editor.isActive("pdf")) {
|
||||||
if (!editor.isActive("pdf")) return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const { selection } = state;
|
const { selection } = state;
|
||||||
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
|
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
|
||||||
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "pdf";
|
const predicate = (node: PMNode) => node.type.name === "pdf";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconPageBreak,
|
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
@@ -165,14 +164,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }: CommandProps) =>
|
command: ({ editor, range }: CommandProps) =>
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Page break",
|
|
||||||
description: "Insert a page break for printing.",
|
|
||||||
searchTerms: ["page", "break", "pagebreak", "print"],
|
|
||||||
icon: IconPageBreak,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor.chain().focus().deleteRange(range).setPageBreak().run(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Image",
|
title: "Image",
|
||||||
description: "Upload any image from your device.",
|
description: "Upload any image from your device.",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ActionIcon, Tooltip } from "@mantine/core";
|
|||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
|
|
||||||
interface SubpagesMenuProps {
|
interface SubpagesMenuProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -34,7 +33,6 @@ export const SubpagesMenu = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return new DOMRect();
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "subpages";
|
const predicate = (node: PMNode) => node.type.name === "subpages";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ export const ColumnHandle = React.memo(function ColumnHandle({
|
|||||||
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
|
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
|
||||||
// unmounting the source element here would make pragmatic-dnd silently
|
// unmounting the source element here would make pragmatic-dnd silently
|
||||||
// abort the active drag.
|
// abort the active drag.
|
||||||
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
|
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
||||||
// an external drop reflows the doc before the plugin re-emits
|
|
||||||
// hoveringCell), it can resolve to a Text node, on which `.closest` is
|
|
||||||
// undefined. Filter to HTMLElement so downstream consumers stay safe.
|
|
||||||
const lookupDom = editor.view.nodeDOM(anchorPos);
|
|
||||||
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
|
||||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,12 +29,7 @@ export const RowHandle = React.memo(function RowHandle({
|
|||||||
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
|
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
|
||||||
// so the handle div stays mounted across stale-anchor renders, otherwise
|
// so the handle div stays mounted across stale-anchor renders, otherwise
|
||||||
// pragmatic-dnd silently aborts an in-flight drag.
|
// pragmatic-dnd silently aborts an in-flight drag.
|
||||||
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
|
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
||||||
// an external drop reflows the doc before the plugin re-emits
|
|
||||||
// hoveringCell), it can resolve to a Text node, on which `.closest` is
|
|
||||||
// undefined. Filter to HTMLElement so downstream consumers stay safe.
|
|
||||||
const lookupDom = editor.view.nodeDOM(anchorPos);
|
|
||||||
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
|
||||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ export const TableMenu = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "table";
|
const predicate = (node: PMNode) => node.type.name === "table";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -54,7 +53,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "video";
|
const predicate = (node: PMNode) => node.type.name === "video";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
TiptapPdf,
|
TiptapPdf,
|
||||||
PageBreak,
|
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
@@ -85,7 +84,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
|||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.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 PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
@@ -367,7 +366,6 @@ export const mainExtensions = [
|
|||||||
TiptapPdf.configure({
|
TiptapPdf.configure({
|
||||||
view: PdfView,
|
view: PdfView,
|
||||||
}),
|
}),
|
||||||
PageBreak,
|
|
||||||
Subpages.configure({
|
Subpages.configure({
|
||||||
view: SubpagesView,
|
view: SubpagesView,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import classes from "@/features/editor/styles/editor.module.css";
|
import classes from "@/features/editor/styles/editor.module.css";
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { TitleEditor } from "@/features/editor/title-editor";
|
import { TitleEditor } from "@/features/editor/title-editor";
|
||||||
import PageEditor from "@/features/editor/page-editor";
|
import PageEditor from "@/features/editor/page-editor";
|
||||||
import {
|
import {
|
||||||
@@ -23,25 +23,17 @@ import { IContributor } from "@/features/page/types/page.types.ts";
|
|||||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
|
||||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
|
||||||
|
|
||||||
type PageUser = {
|
type PageCreator = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Module-level flag: survives component unmount/remount on page navigation,
|
|
||||||
// reset only on full page reload (i.e. a new app session).
|
|
||||||
let defaultEditModeApplied = false;
|
|
||||||
|
|
||||||
export interface FullEditorProps {
|
export interface FullEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
@@ -49,7 +41,7 @@ export interface FullEditorProps {
|
|||||||
content: string;
|
content: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
creator?: PageUser;
|
creator?: PageCreator;
|
||||||
contributors?: IContributor[];
|
contributors?: IContributor[];
|
||||||
canComment?: boolean;
|
canComment?: boolean;
|
||||||
}
|
}
|
||||||
@@ -69,21 +61,9 @@ export function FullEditor({
|
|||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user.settings?.preferences?.editorToolbar ?? false;
|
user.settings?.preferences?.editorToolbar ?? false;
|
||||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
|
||||||
currentPageEditModeAtom,
|
|
||||||
);
|
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
const isEditMode = userPageEditMode === PageEditMode.Edit;
|
||||||
|
|
||||||
// Apply the user's saved preference only once on initial load, not on every
|
|
||||||
// page navigation — so the mode sticks across navigations within a session.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!defaultEditModeApplied) {
|
|
||||||
setCurrentPageEditMode(userPageEditMode as PageEditMode);
|
|
||||||
defaultEditModeApplied = true;
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, setCurrentPageEditMode]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
@@ -91,10 +71,7 @@ export function FullEditor({
|
|||||||
size={!fullPageWidth && 900}
|
size={!fullPageWidth && 900}
|
||||||
className={classes.editor}
|
className={classes.editor}
|
||||||
>
|
>
|
||||||
{editorToolbarEnabled && editable && isEditMode && (
|
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
|
||||||
<MemoizedFixedToolbar />
|
|
||||||
)}
|
|
||||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
|
||||||
<MemoizedTitleEditor
|
<MemoizedTitleEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
slugId={slugId}
|
slugId={slugId}
|
||||||
@@ -118,12 +95,16 @@ export function FullEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PageBylineProps = {
|
type PageBylineProps = {
|
||||||
creator?: PageUser;
|
creator?: PageCreator;
|
||||||
contributors?: IContributor[];
|
contributors?: IContributor[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
|
function PageByline({
|
||||||
|
creator,
|
||||||
|
contributors,
|
||||||
|
readOnly,
|
||||||
|
}: PageBylineProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ import {
|
|||||||
collabExtensions,
|
collabExtensions,
|
||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
@@ -55,7 +54,7 @@ import {
|
|||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
@@ -113,7 +112,8 @@ export default function PageEditor({
|
|||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const userPageEditMode =
|
||||||
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const canScroll = useCallback(
|
const canScroll = useCallback(
|
||||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
@@ -373,9 +373,19 @@ export default function PageEditor({
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
// Only honor user default page edit mode preference and permissions
|
||||||
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
if (editor) {
|
||||||
}, [currentPageEditMode, editor, editable]);
|
if (userPageEditMode && editable) {
|
||||||
|
if (userPageEditMode === PageEditMode.Edit) {
|
||||||
|
editor.setEditable(true);
|
||||||
|
} else if (userPageEditMode === PageEditMode.Read) {
|
||||||
|
editor.setEditable(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editor.setEditable(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [userPageEditMode, editor, editable]);
|
||||||
|
|
||||||
const hasConnectedOnceRef = useRef(false);
|
const hasConnectedOnceRef = useRef(false);
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
const [showStatic, setShowStatic] = useState(true);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
@import "./print.css";
|
@import "./print.css";
|
||||||
@import "./page-break.css";
|
|
||||||
@import "./find.css";
|
@import "./find.css";
|
||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
.ProseMirror .page-break {
|
|
||||||
position: relative;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
border-top: 1px dashed var(--mantine-color-default-border);
|
|
||||||
height: 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror[contenteditable="false"] .page-break {
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror[contenteditable="false"] .page-break::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror .page-break::after {
|
|
||||||
content: "Page break";
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
background: var(--mantine-color-body);
|
|
||||||
color: var(--mantine-color-dimmed);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror .page-break.ProseMirror-selectednode {
|
|
||||||
border-top-color: var(--mantine-primary-color-filled);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.ProseMirror .page-break {
|
|
||||||
break-before: always;
|
|
||||||
page-break-before: always;
|
|
||||||
visibility: hidden;
|
|
||||||
border: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror .page-break::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { Text } from "@tiptap/extension-text";
|
|||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
@@ -25,6 +24,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
||||||
import { UpdateEvent } from "@/features/websocket/types";
|
import { UpdateEvent } from "@/features/websocket/types";
|
||||||
import localEmitter from "@/lib/local-emitter.ts";
|
import localEmitter from "@/lib/local-emitter.ts";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { platformModifierKey } from "@/lib";
|
import { platformModifierKey } from "@/lib";
|
||||||
@@ -52,7 +52,9 @@ export function TitleEditor({
|
|||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
const [activePageId, setActivePageId] = useState(pageId);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const userPageEditMode =
|
||||||
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -170,9 +172,18 @@ export function TitleEditor({
|
|||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!titleEditor) return;
|
if (titleEditor) {
|
||||||
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
if (userPageEditMode && editable) {
|
||||||
}, [currentPageEditMode, titleEditor, editable]);
|
if (userPageEditMode === PageEditMode.Edit) {
|
||||||
|
titleEditor.setEditable(true);
|
||||||
|
} else if (userPageEditMode === PageEditMode.Read) {
|
||||||
|
titleEditor.setEditable(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
titleEditor.setEditable(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [userPageEditMode, titleEditor, editable]);
|
||||||
|
|
||||||
const openSearchDialog = () => {
|
const openSearchDialog = () => {
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
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 { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
@@ -65,11 +65,6 @@ interface PageHeaderMenuProps {
|
|||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const { data: page } = usePageQuery({
|
|
||||||
pageId: extractPageSlugId(pageSlug),
|
|
||||||
});
|
|
||||||
const isDeleted = !!page?.deletedAt;
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
@@ -92,15 +87,11 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isDeleted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectionWarning />
|
<ConnectionWarning />
|
||||||
|
|
||||||
{!readOnly && <PageEditModeToggle size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
<PageShareModal readOnly={readOnly} />
|
<PageShareModal readOnly={readOnly} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type UseRestoreModalProps = {
|
|
||||||
title?: string | null;
|
|
||||||
onConfirm: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useRestorePageModal() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const openRestoreModal = ({ title, onConfirm }: UseRestoreModalProps) => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Restore page"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Restore '{{title}}' and its sub-pages?", {
|
|
||||||
title: title || t("Untitled"),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "blue" },
|
|
||||||
onConfirm,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { openRestoreModal } as const;
|
|
||||||
}
|
|
||||||
@@ -117,20 +117,10 @@ export function useUpdatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRemovePageMutation() {
|
export function useRemovePageMutation() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||||
onSuccess: (_, pageId) => {
|
onSuccess: (_, pageId) => {
|
||||||
notifications.show({ message: t("Page moved to trash") });
|
notifications.show({ message: "Page moved to trash" });
|
||||||
|
|
||||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
|
||||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
|
||||||
if (cached) {
|
|
||||||
const stamped = { ...cached, deletedAt: new Date() };
|
|
||||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
|
||||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: (item) =>
|
predicate: (item) =>
|
||||||
@@ -138,7 +128,7 @@ export function useRemovePageMutation() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,14 +162,13 @@ export function useMovePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRestorePageMutation() {
|
export function useRestorePageMutation() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => restorePage(pageId),
|
mutationFn: (pageId: string) => restorePage(pageId),
|
||||||
onSuccess: async (restoredPage) => {
|
onSuccess: async (restoredPage) => {
|
||||||
notifications.show({ message: t("Page restored successfully") });
|
notifications.show({ message: "Page restored successfully" });
|
||||||
|
|
||||||
// Check if the page already exists in the tree (it shouldn't)
|
// Check if the page already exists in the tree (it shouldn't)
|
||||||
if (!treeModel.find(treeData, restoredPage.id)) {
|
if (!treeModel.find(treeData, restoredPage.id)) {
|
||||||
@@ -233,16 +222,9 @@ export function useRestorePageMutation() {
|
|||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["trash-list", restoredPage.spaceId],
|
queryKey: ["trash-list", restoredPage.spaceId],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge — restore endpoint returns a skinny page;
|
|
||||||
// Replace would strip space/permissions/content and break the editor.
|
|
||||||
const merge = (cached: IPage | undefined) =>
|
|
||||||
cached ? { ...cached, ...restoredPage } : cached;
|
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to restore page"), color: "red" });
|
notifications.show({ message: "Failed to restore page", color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { ActionIcon, Button, Group, Paper, Text, Tooltip } from "@mantine/core";
|
|
||||||
import { IconRestore, IconTrash } from "@tabler/icons-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
|
||||||
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
|
||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
|
||||||
import {
|
|
||||||
useDeletePageMutation,
|
|
||||||
usePageQuery,
|
|
||||||
useRestorePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query.ts";
|
|
||||||
import { getSpaceUrl } from "@/lib/config.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";
|
|
||||||
|
|
||||||
type DeletedPageBannerProps = {
|
|
||||||
slugId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: page } = usePageQuery({ pageId: slugId });
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
|
||||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
|
||||||
const deletedTimeAgo = useTimeAgo(page?.deletedAt);
|
|
||||||
const restorePageMutation = useRestorePageMutation();
|
|
||||||
const deletePageMutation = useDeletePageMutation();
|
|
||||||
const { openRestoreModal } = useRestorePageModal();
|
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
|
||||||
|
|
||||||
if (!page?.deletedAt) return null;
|
|
||||||
|
|
||||||
const canRestore = spaceAbility.can(
|
|
||||||
SpaceCaslAction.Edit,
|
|
||||||
SpaceCaslSubject.Page,
|
|
||||||
);
|
|
||||||
const canPermanentlyDelete = spaceAbility.can(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Settings,
|
|
||||||
);
|
|
||||||
const actorName = page.deletedBy?.name ?? t("Someone");
|
|
||||||
|
|
||||||
const handleRestore = () => {
|
|
||||||
openRestoreModal({
|
|
||||||
title: page.title,
|
|
||||||
onConfirm: () => restorePageMutation.mutate(page.id),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePermanentDelete = () => {
|
|
||||||
openDeleteModal({
|
|
||||||
isPermanent: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
await deletePageMutation.mutateAsync(page.id);
|
|
||||||
navigate(getSpaceUrl(page.space?.slug));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasAnyAction = canRestore || canPermanentlyDelete;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="red.0">
|
|
||||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
|
||||||
<Text size="sm" style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Trans
|
|
||||||
i18nKey="<b>{{name}}</b> moved this page to Trash {{time}}."
|
|
||||||
values={{ name: actorName, time: deletedTimeAgo }}
|
|
||||||
components={{ b: <Text span fw={600} inherit /> }}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
{hasAnyAction && (
|
|
||||||
<>
|
|
||||||
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
|
||||||
{canRestore && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconRestore size={16} />}
|
|
||||||
onClick={handleRestore}
|
|
||||||
loading={restorePageMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Restore page")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canPermanentlyDelete && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
onClick={handlePermanentDelete}
|
|
||||||
loading={deletePageMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Permanently delete")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
|
||||||
{canRestore && (
|
|
||||||
<Tooltip label={t("Restore page")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
onClick={handleRestore}
|
|
||||||
loading={restorePageMutation.isPending}
|
|
||||||
aria-label={t("Restore page")}
|
|
||||||
>
|
|
||||||
<IconRestore size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{canPermanentlyDelete && (
|
|
||||||
<Tooltip label={t("Permanently delete")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
size="lg"
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
onClick={handlePermanentDelete}
|
|
||||||
loading={deletePageMutation.isPending}
|
|
||||||
aria-label={t("Permanently delete")}
|
|
||||||
>
|
|
||||||
<IconTrash size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Alert, Text } from "@mantine/core";
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
export function TrashBanner() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const retentionDays = workspace?.trashRetentionDays ?? 30;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
|
||||||
<Text size="sm" lh={1.35}>
|
|
||||||
{t("Pages in trash will be permanently deleted after {{count}} days.", {
|
|
||||||
count: retentionDays,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,17 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
|
Alert,
|
||||||
Stack,
|
Stack,
|
||||||
Menu,
|
Menu,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconInfoCircle,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconRestore,
|
IconRestore,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
|
|
||||||
import {
|
import {
|
||||||
useDeletedPagesQuery,
|
useDeletedPagesQuery,
|
||||||
useRestorePageMutation,
|
useRestorePageMutation,
|
||||||
@@ -30,10 +31,12 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
|
|||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
@@ -42,7 +45,6 @@ export default function Trash() {
|
|||||||
});
|
});
|
||||||
const restorePageMutation = useRestorePageMutation();
|
const restorePageMutation = useRestorePageMutation();
|
||||||
const deletePageMutation = useDeletePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
const { openRestoreModal } = useRestorePageModal();
|
|
||||||
|
|
||||||
const [selectedPage, setSelectedPage] = useState<{
|
const [selectedPage, setSelectedPage] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
@@ -76,6 +78,23 @@ export default function Trash() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRestoreModal = (pageId: string, pageTitle: string) => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Restore page"),
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t("Restore '{{title}}' and its sub-pages?", {
|
||||||
|
title: pageTitle || "Untitled",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
centered: true,
|
||||||
|
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
||||||
|
confirmProps: { color: "blue" },
|
||||||
|
onConfirm: () => handleRestorePage(pageId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
const hasPages = deletedPages && deletedPages.items.length > 0;
|
||||||
|
|
||||||
const handlePageClick = (page: any) => {
|
const handlePageClick = (page: any) => {
|
||||||
@@ -90,7 +109,11 @@ export default function Trash() {
|
|||||||
<Title order={2}>{t("Trash")}</Title>
|
<Title order={2}>{t("Trash")}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<TrashBanner />
|
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
||||||
|
<Text size="sm">
|
||||||
|
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
{isLoading || !deletedPages ? (
|
{isLoading || !deletedPages ? (
|
||||||
<></>
|
<></>
|
||||||
@@ -158,10 +181,7 @@ export default function Trash() {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconRestore size={16} />}
|
leftSection={<IconRestore size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openRestoreModal({
|
openRestoreModal(page.id, page.title)
|
||||||
title: page.title,
|
|
||||||
onConfirm: () => handleRestorePage(page.id),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("Restore")}
|
{t("Restore")}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
|
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
|
||||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
|
|
||||||
export default function PageStatePref() {
|
export default function PageStatePref() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -72,24 +71,3 @@ export function PageStateSegmentedControl({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header variant: updates the current page's mode locally without persisting
|
|
||||||
// the preference to the server.
|
|
||||||
export function PageEditModeToggle({ size }: { size?: MantineSize }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
|
||||||
currentPageEditModeAtom,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SegmentedControl
|
|
||||||
size={size}
|
|
||||||
value={currentPageEditMode}
|
|
||||||
onChange={(v) => setCurrentPageEditMode(v as PageEditMode)}
|
|
||||||
data={[
|
|
||||||
{ label: t("Edit"), value: PageEditMode.Edit },
|
|
||||||
{ label: t("Read"), value: PageEditMode.Read },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,7 +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 canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
const canComment =
|
const canComment =
|
||||||
canEdit ||
|
canEdit ||
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
(space?.settings?.comments?.allowViewerComments === true);
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
advancedChunks: {
|
codeSplitting: {
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{ name: "vendor-mantine", test: /@mantine/ },
|
||||||
name: "vendor-mantine",
|
{ name: "vendor-mermaid", test: /mermaid|cytoscape|elkjs/ },
|
||||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
{ name: "vendor-excalidraw", test: /excalidraw/ },
|
||||||
},
|
{ name: "vendor-katex", test: /katex/ },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"kysely": "^0.28.17",
|
"kysely": "^0.29.0",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"kysely-postgres-js": "^3.0.0",
|
"kysely-postgres-js": "^3.0.0",
|
||||||
"ldapts": "^8.1.7",
|
"ldapts": "^8.1.7",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TiptapAudio,
|
TiptapAudio,
|
||||||
TiptapPdf,
|
TiptapPdf,
|
||||||
PageBreak,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
Attachment,
|
Attachment,
|
||||||
Drawio,
|
Drawio,
|
||||||
@@ -95,7 +94,6 @@ export const tiptapExtensions = [
|
|||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TiptapAudio,
|
TiptapAudio,
|
||||||
TiptapPdf,
|
TiptapPdf,
|
||||||
PageBreak,
|
|
||||||
Callout,
|
Callout,
|
||||||
Attachment,
|
Attachment,
|
||||||
CustomCodeBlock,
|
CustomCodeBlock,
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import {
|
|||||||
HISTORY_INTERVAL,
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
@@ -49,7 +47,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
private readonly transclusionService: TransclusionService,
|
private readonly transclusionService: TransclusionService,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
@@ -202,19 +199,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.enqueuePageHistory(page);
|
await this.enqueuePageHistory(page);
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
page.workspaceId,
|
|
||||||
WebhookEvent.PageUpdated,
|
|
||||||
{
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
title: page.title,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
workspaceId: page.workspaceId,
|
|
||||||
updatedAt: page.updatedAt,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 { EnvironmentModule } from '../../integrations/environment/environment.module';
|
import { EnvironmentModule } from '../../integrations/environment/environment.module';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
import { CollaborationModule } from '../collaboration.module';
|
import { CollaborationModule } from '../collaboration.module';
|
||||||
import { DatabaseModule } from '@docmost/db/database.module';
|
import { DatabaseModule } from '@docmost/db/database.module';
|
||||||
import { QueueModule } from '../../integrations/queue/queue.module';
|
import { QueueModule } from '../../integrations/queue/queue.module';
|
||||||
@@ -13,8 +12,6 @@ import { LoggerModule } from '../../common/logger/logger.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 { CaslModule } from '../../core/casl/casl.module';
|
import { CaslModule } from '../../core/casl/casl.module';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
|
||||||
import KeyvRedis from '@keyv/redis';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -29,18 +26,6 @@ import KeyvRedis from '@keyv/redis';
|
|||||||
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],
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AppController,
|
AppController,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const Feature = {
|
|||||||
VIEWER_COMMENTS: 'comment:viewer',
|
VIEWER_COMMENTS: 'comment:viewer',
|
||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
PDF_EXPORT: 'export:pdf',
|
PDF_EXPORT: 'export:pdf',
|
||||||
WEBHOOKS: 'webhooks',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
export const CacheKey = {
|
export const CacheKey = {
|
||||||
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
|
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
|
||||||
SPACE_ROLES: (userId: string, spaceId: string) =>
|
|
||||||
`perm:space-roles:${userId}:${spaceId}`,
|
|
||||||
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
|
|
||||||
`perm:can-edit:${userId}:${pageId}`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Permission caches dedupe repeated checks within and across short request bursts.
|
|
||||||
// 5s keeps staleness on revocations bounded.
|
|
||||||
export const PERMISSION_CACHE_TTL_MS = 5_000;
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Cache } from 'cache-manager';
|
|
||||||
|
|
||||||
export async function withCache<T>(
|
|
||||||
cacheManager: Cache,
|
|
||||||
key: string,
|
|
||||||
ttlMs: number,
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
const cached = await cacheManager.get<{ v: T }>(key);
|
|
||||||
if (cached !== undefined && cached !== null) {
|
|
||||||
return cached.v;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = await fn();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await cacheManager.set(key, { v: value }, ttlMs);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[withCache] set failed for "${key}"`, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,6 @@ import { InjectQueue } from '@nestjs/bullmq';
|
|||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { createByteCountingStream } from '../../../common/helpers/utils';
|
import { createByteCountingStream } from '../../../common/helpers/utils';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentService {
|
export class AttachmentService {
|
||||||
@@ -41,7 +39,6 @@ export class AttachmentService {
|
|||||||
private readonly spaceRepo: SpaceRepo,
|
private readonly spaceRepo: SpaceRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async uploadFile(opts: {
|
async uploadFile(opts: {
|
||||||
@@ -274,7 +271,7 @@ export class AttachmentService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
trx,
|
trx,
|
||||||
} = opts;
|
} = opts;
|
||||||
const attachment = await this.attachmentRepo.insertAttachment(
|
return this.attachmentRepo.insertAttachment(
|
||||||
{
|
{
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -290,23 +287,6 @@ export class AttachmentService {
|
|||||||
},
|
},
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.AttachmentUploaded,
|
|
||||||
{
|
|
||||||
id: attachment.id,
|
|
||||||
fileName: attachment.fileName,
|
|
||||||
mimeType: attachment.mimeType,
|
|
||||||
fileSize: attachment.fileSize,
|
|
||||||
pageId: attachment.pageId,
|
|
||||||
spaceId: attachment.spaceId,
|
|
||||||
workspaceId: attachment.workspaceId,
|
|
||||||
creatorId: attachment.creatorId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return attachment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDeleteAiChatAttachments(aiChatId: string) {
|
async handleDeleteAiChatAttachments(aiChatId: string) {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ function buildWorkspaceOwnerAbility() {
|
|||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook);
|
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
@@ -59,7 +58,6 @@ function buildWorkspaceAdminAbility() {
|
|||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook);
|
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export enum WorkspaceCaslSubject {
|
|||||||
Attachment = 'attachment',
|
Attachment = 'attachment',
|
||||||
API = 'api_key',
|
API = 'api_key',
|
||||||
Audit = 'audit',
|
Audit = 'audit',
|
||||||
Webhook = 'webhook',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IWorkspaceAbility =
|
export type IWorkspaceAbility =
|
||||||
@@ -23,5 +22,4 @@ export type IWorkspaceAbility =
|
|||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit];
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Webhook];
|
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import {
|
|||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../integrations/audit/audit.service';
|
} from '../../integrations/audit/audit.service';
|
||||||
import { WsService } from '../../ws/ws.service';
|
import { WsService } from '../../ws/ws.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('comments')
|
@Controller('comments')
|
||||||
@@ -46,7 +44,6 @@ export class CommentController {
|
|||||||
private readonly pageAccessService: PageAccessService,
|
private readonly pageAccessService: PageAccessService,
|
||||||
private readonly wsService: WsService,
|
private readonly wsService: WsService,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -195,16 +192,5 @@ export class CommentController {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
comment.workspaceId,
|
|
||||||
WebhookEvent.CommentDeleted,
|
|
||||||
{
|
|
||||||
id: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
spaceId: comment.spaceId,
|
|
||||||
workspaceId: comment.workspaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
|||||||
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
||||||
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
|
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
|
||||||
import { WsService } from '../../ws/ws.service';
|
import { WsService } from '../../ws/ws.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
@@ -35,7 +33,6 @@ export class CommentService {
|
|||||||
private generalQueue: Queue,
|
private generalQueue: Queue,
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||||
private notificationQueue: Queue,
|
private notificationQueue: Queue,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -145,21 +142,6 @@ export class CommentService {
|
|||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.CommentCreated,
|
|
||||||
{
|
|
||||||
id: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
spaceId: comment.spaceId,
|
|
||||||
workspaceId: comment.workspaceId,
|
|
||||||
type: comment.type,
|
|
||||||
content: comment.content,
|
|
||||||
creatorId: comment.creatorId,
|
|
||||||
createdAt: comment.createdAt,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,22 +203,6 @@ export class CommentService {
|
|||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
comment.workspaceId,
|
|
||||||
WebhookEvent.CommentUpdated,
|
|
||||||
{
|
|
||||||
id: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
spaceId: comment.spaceId,
|
|
||||||
workspaceId: comment.workspaceId,
|
|
||||||
type: comment.type,
|
|
||||||
content: comment.content,
|
|
||||||
creatorId: comment.creatorId,
|
|
||||||
createdAt: comment.createdAt,
|
|
||||||
updatedAt: comment.updatedAt,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ import {
|
|||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../integrations/audit/audit.service';
|
} from '../../integrations/audit/audit.service';
|
||||||
import { getPageTitle } from '../../common/helpers';
|
import { getPageTitle } from '../../common/helpers';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -67,7 +65,6 @@ export class PageController {
|
|||||||
private readonly backlinkService: BacklinkService,
|
private readonly backlinkService: BacklinkService,
|
||||||
private readonly labelService: LabelService,
|
private readonly labelService: LabelService,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -79,7 +76,6 @@ export class PageController {
|
|||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
includeLastUpdatedBy: true,
|
includeLastUpdatedBy: true,
|
||||||
includeContributors: true,
|
includeContributors: true,
|
||||||
includeDeletedBy: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -369,18 +365,6 @@ export class PageController {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspace.id,
|
|
||||||
WebhookEvent.PageDeleted,
|
|
||||||
{
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
title: page.title,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,18 +405,6 @@ export class PageController {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspace.id,
|
|
||||||
WebhookEvent.PageRestored,
|
|
||||||
{
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
title: page.title,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||||
includeHasChildren: true,
|
includeHasChildren: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
|||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -75,7 +73,6 @@ export class PageService {
|
|||||||
private collaborationGateway: CollaborationGateway,
|
private collaborationGateway: CollaborationGateway,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
private readonly transclusionService: TransclusionService,
|
private readonly transclusionService: TransclusionService,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@@ -159,30 +156,9 @@ export class PageService {
|
|||||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
page.workspaceId,
|
|
||||||
WebhookEvent.PageCreated,
|
|
||||||
this.toWebhookPagePayload(page),
|
|
||||||
);
|
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toWebhookPagePayload(page: Page) {
|
|
||||||
return {
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
title: page.title,
|
|
||||||
icon: page.icon,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
workspaceId: page.workspaceId,
|
|
||||||
creatorId: page.creatorId,
|
|
||||||
createdAt: page.createdAt,
|
|
||||||
updatedAt: page.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||||
let pagePosition: string;
|
let pagePosition: string;
|
||||||
|
|
||||||
@@ -269,21 +245,13 @@ export class PageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPage = await this.pageRepo.findById(page.id, {
|
return await this.pageRepo.findById(page.id, {
|
||||||
includeSpace: true,
|
includeSpace: true,
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
includeLastUpdatedBy: true,
|
includeLastUpdatedBy: true,
|
||||||
includeContributors: true,
|
includeContributors: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
updatedPage.workspaceId,
|
|
||||||
WebhookEvent.PageUpdated,
|
|
||||||
this.toWebhookPagePayload(updatedPage),
|
|
||||||
);
|
|
||||||
|
|
||||||
return updatedPage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePageContent(
|
async updatePageContent(
|
||||||
@@ -519,18 +487,6 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
rootPage.workspaceId,
|
|
||||||
WebhookEvent.PageMoved,
|
|
||||||
{
|
|
||||||
id: rootPage.id,
|
|
||||||
slugId: rootPage.slugId,
|
|
||||||
fromSpaceId: rootPage.spaceId,
|
|
||||||
toSpaceId: spaceId,
|
|
||||||
workspaceId: rootPage.workspaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { childPageIds };
|
return { childPageIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,14 +1015,6 @@ export class PageService {
|
|||||||
pageIds: pageIds,
|
pageIds: pageIds,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const id of pageIds) {
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.PageDeleted,
|
|
||||||
{ id, workspaceId },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ import {
|
|||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../../integrations/audit/audit.service';
|
} from '../../../integrations/audit/audit.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceService {
|
export class SpaceService {
|
||||||
@@ -43,7 +41,6 @@ export class SpaceService {
|
|||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createSpace(
|
async createSpace(
|
||||||
@@ -88,17 +85,6 @@ export class SpaceService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.SpaceCreated,
|
|
||||||
{
|
|
||||||
id: space.id,
|
|
||||||
name: space.name,
|
|
||||||
slug: space.slug,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...space, memberCount: 1 };
|
return { ...space, memberCount: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,17 +244,6 @@ export class SpaceService {
|
|||||||
spaceId: updateSpaceDto.spaceId,
|
spaceId: updateSpaceDto.spaceId,
|
||||||
changes: { before, after },
|
changes: { before, after },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.SpaceUpdated,
|
|
||||||
{
|
|
||||||
id: updatedSpace.id,
|
|
||||||
name: updatedSpace.name,
|
|
||||||
slug: updatedSpace.slug,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedSpace;
|
return updatedSpace;
|
||||||
@@ -314,15 +289,5 @@ export class SpaceService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.SpaceDeleted,
|
|
||||||
{
|
|
||||||
id: spaceId,
|
|
||||||
name: space.name,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ import {
|
|||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../../integrations/audit/audit.service';
|
} from '../../../integrations/audit/audit.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceInvitationService {
|
export class WorkspaceInvitationService {
|
||||||
@@ -57,7 +55,6 @@ export class WorkspaceInvitationService {
|
|||||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
|
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
|
||||||
@@ -307,18 +304,6 @@ export class WorkspaceInvitationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspace.id,
|
|
||||||
WebhookEvent.UserCreated,
|
|
||||||
{
|
|
||||||
id: newUser.id,
|
|
||||||
name: newUser.name,
|
|
||||||
email: newUser.email,
|
|
||||||
role: newUser.role,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// notify the inviter
|
// notify the inviter
|
||||||
const invitedByUser = await this.userRepo.findById(
|
const invitedByUser = await this.userRepo.findById(
|
||||||
invitation.invitedById,
|
invitation.invitedById,
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ import {
|
|||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../../integrations/audit/audit.service';
|
} from '../../../integrations/audit/audit.service';
|
||||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
|
||||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
@@ -74,7 +72,6 @@ export class WorkspaceService {
|
|||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
private userSessionRepo: UserSessionRepo,
|
private userSessionRepo: UserSessionRepo,
|
||||||
private readonly webhookDispatcher: WebhookDispatcher,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(workspaceId: string) {
|
async findById(workspaceId: string) {
|
||||||
@@ -739,17 +736,6 @@ export class WorkspaceService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webhookDispatcher.dispatch(
|
|
||||||
workspaceId,
|
|
||||||
WebhookEvent.UserDeactivated,
|
|
||||||
{
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateUser(
|
async activateUser(
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('webhooks')
|
|
||||||
.ifNotExists()
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('url', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('signing_secret', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('subscribed_events', 'jsonb', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`'[]'::jsonb`),
|
|
||||||
)
|
|
||||||
.addColumn('is_active', 'boolean', (col) =>
|
|
||||||
col.notNull().defaultTo(true),
|
|
||||||
)
|
|
||||||
.addColumn('consecutive_failure_count', 'integer', (col) =>
|
|
||||||
col.notNull().defaultTo(0),
|
|
||||||
)
|
|
||||||
.addColumn('disabled_at', 'timestamptz')
|
|
||||||
.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
|
|
||||||
.createIndex('idx_webhooks_workspace_id')
|
|
||||||
.ifNotExists()
|
|
||||||
.on('webhooks')
|
|
||||||
.columns(['workspace_id', 'id desc'])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_webhooks_subscribed_events')
|
|
||||||
.ifNotExists()
|
|
||||||
.on('webhooks')
|
|
||||||
.using('gin')
|
|
||||||
.column('subscribed_events')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable('webhook_deliveries')
|
|
||||||
.ifNotExists()
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('webhook_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('webhooks.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('event', 'varchar', (col) => col.notNull())
|
|
||||||
.addColumn('payload', 'jsonb', (col) => col.notNull())
|
|
||||||
.addColumn('status', 'varchar', (col) =>
|
|
||||||
col.notNull().defaultTo('pending'),
|
|
||||||
)
|
|
||||||
.addColumn('http_status', 'integer')
|
|
||||||
.addColumn('response_body', 'text')
|
|
||||||
.addColumn('error_message', 'text')
|
|
||||||
.addColumn('attempt_count', 'integer', (col) =>
|
|
||||||
col.notNull().defaultTo(0),
|
|
||||||
)
|
|
||||||
.addColumn('duration_ms', 'integer')
|
|
||||||
.addColumn('delivered_at', 'timestamptz')
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_webhook_deliveries_webhook_id')
|
|
||||||
.ifNotExists()
|
|
||||||
.on('webhook_deliveries')
|
|
||||||
.columns(['webhook_id', 'id desc'])
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.dropTable('webhook_deliveries').execute();
|
|
||||||
await db.schema.dropTable('webhooks').execute();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
||||||
import { Cache } from 'cache-manager';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
@@ -19,11 +17,6 @@ import {
|
|||||||
executeWithCursorPagination,
|
executeWithCursorPagination,
|
||||||
} from '@docmost/db/pagination/cursor-pagination';
|
} from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { PagePermissionMember } from './types/page-permission.types';
|
import { PagePermissionMember } from './types/page-permission.types';
|
||||||
import { withCache } from '../../../common/helpers/with-cache';
|
|
||||||
import {
|
|
||||||
CacheKey,
|
|
||||||
PERMISSION_CACHE_TTL_MS,
|
|
||||||
} from '../../../common/helpers/cache-keys';
|
|
||||||
|
|
||||||
export { PagePermissionMember } from './types/page-permission.types';
|
export { PagePermissionMember } from './types/page-permission.types';
|
||||||
|
|
||||||
@@ -32,7 +25,6 @@ export class PagePermissionRepo {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly groupRepo: GroupRepo,
|
private readonly groupRepo: GroupRepo,
|
||||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findPageAccessByPageId(
|
async findPageAccessByPageId(
|
||||||
@@ -369,8 +361,40 @@ export class PagePermissionRepo {
|
|||||||
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
|
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
|
||||||
*/
|
*/
|
||||||
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
|
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
|
||||||
const { canAccess } = await this.canUserEditPage(userId, pageId);
|
const deniedAncestor = await this.db
|
||||||
return canAccess;
|
.withRecursive('ancestors', (qb) =>
|
||||||
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||||
|
.where('pages.id', '=', pageId)
|
||||||
|
.unionAll((eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||||
|
.select(['pages.id as ancestorId', 'pages.parentPageId']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestors')
|
||||||
|
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||||
|
.leftJoin('pagePermissions', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
||||||
|
.on((eb) =>
|
||||||
|
eb.or([
|
||||||
|
eb('pagePermissions.userId', '=', userId),
|
||||||
|
eb(
|
||||||
|
'pagePermissions.groupId',
|
||||||
|
'in',
|
||||||
|
this.userGroupIdsSubquery(eb, userId),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.select('pageAccess.pageId')
|
||||||
|
.where('pagePermissions.id', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return !deniedAncestor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -388,11 +412,6 @@ export class PagePermissionRepo {
|
|||||||
canAccess: boolean;
|
canAccess: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
}> {
|
}> {
|
||||||
return withCache(
|
|
||||||
this.cacheManager,
|
|
||||||
CacheKey.PAGE_CAN_EDIT(userId, pageId),
|
|
||||||
PERMISSION_CACHE_TTL_MS,
|
|
||||||
async () => {
|
|
||||||
const result = await sql<{
|
const result = await sql<{
|
||||||
canAccess: boolean | null;
|
canAccess: boolean | null;
|
||||||
canEdit: boolean | null;
|
canEdit: boolean | null;
|
||||||
@@ -430,8 +449,6 @@ export class PagePermissionRepo {
|
|||||||
canAccess: row.canAccess,
|
canAccess: row.canAccess,
|
||||||
canEdit: row.canAccess && (row.canEdit ?? false),
|
canEdit: row.canAccess && (row.canEdit ?? false),
|
||||||
};
|
};
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export class PageRepo {
|
|||||||
includeCreator?: boolean;
|
includeCreator?: boolean;
|
||||||
includeLastUpdatedBy?: boolean;
|
includeLastUpdatedBy?: boolean;
|
||||||
includeContributors?: boolean;
|
includeContributors?: boolean;
|
||||||
includeDeletedBy?: boolean;
|
|
||||||
includeHasChildren?: boolean;
|
includeHasChildren?: boolean;
|
||||||
withLock?: boolean;
|
withLock?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
@@ -84,10 +83,6 @@ export class PageRepo {
|
|||||||
query = query.select((eb) => this.withContributors(eb));
|
query = query.select((eb) => this.withContributors(eb));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts?.includeDeletedBy) {
|
|
||||||
query = query.select((eb) => this.withDeletedBy(eb));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.includeSpace) {
|
if (opts?.includeSpace) {
|
||||||
query = query.select((eb) => this.withSpace(eb));
|
query = query.select((eb) => this.withSpace(eb));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
||||||
import { Cache } from 'cache-manager';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
@@ -15,11 +13,6 @@ import { MemberInfo, UserSpaceRole } from './types';
|
|||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
import { withCache } from '../../../common/helpers/with-cache';
|
|
||||||
import {
|
|
||||||
CacheKey,
|
|
||||||
PERMISSION_CACHE_TTL_MS,
|
|
||||||
} from '../../../common/helpers/cache-keys';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceMemberRepo {
|
export class SpaceMemberRepo {
|
||||||
@@ -27,7 +20,6 @@ export class SpaceMemberRepo {
|
|||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly groupRepo: GroupRepo,
|
private readonly groupRepo: GroupRepo,
|
||||||
private readonly spaceRepo: SpaceRepo,
|
private readonly spaceRepo: SpaceRepo,
|
||||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async insertSpaceMember(
|
async insertSpaceMember(
|
||||||
@@ -222,11 +214,6 @@ export class SpaceMemberRepo {
|
|||||||
userId: string,
|
userId: string,
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
): Promise<UserSpaceRole[]> {
|
): Promise<UserSpaceRole[]> {
|
||||||
return withCache(
|
|
||||||
this.cacheManager,
|
|
||||||
CacheKey.SPACE_ROLES(userId, spaceId),
|
|
||||||
PERMISSION_CACHE_TTL_MS,
|
|
||||||
async () => {
|
|
||||||
const roles = await this.db
|
const roles = await this.db
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.select(['userId', 'role'])
|
.select(['userId', 'role'])
|
||||||
@@ -235,11 +222,7 @@ export class SpaceMemberRepo {
|
|||||||
.unionAll(
|
.unionAll(
|
||||||
this.db
|
this.db
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.innerJoin(
|
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||||
'groupUsers',
|
|
||||||
'groupUsers.groupId',
|
|
||||||
'spaceMembers.groupId',
|
|
||||||
)
|
|
||||||
.select(['groupUsers.userId', 'spaceMembers.role'])
|
.select(['groupUsers.userId', 'spaceMembers.role'])
|
||||||
.where('groupUsers.userId', '=', userId)
|
.where('groupUsers.userId', '=', userId)
|
||||||
.where('spaceMembers.spaceId', '=', spaceId),
|
.where('spaceMembers.spaceId', '=', spaceId),
|
||||||
@@ -250,8 +233,6 @@ export class SpaceMemberRepo {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return roles;
|
return roles;
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserIdsWithSpaceAccess(
|
async getUserIdsWithSpaceAccess(
|
||||||
|
|||||||
-33
@@ -589,37 +589,6 @@ export interface UserSessions {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Webhooks {
|
|
||||||
id: Generated<string>;
|
|
||||||
workspaceId: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
signingSecret: string;
|
|
||||||
subscribedEvents: Generated<Json>;
|
|
||||||
isActive: Generated<boolean>;
|
|
||||||
consecutiveFailureCount: Generated<number>;
|
|
||||||
disabledAt: Timestamp | null;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebhookDeliveries {
|
|
||||||
id: Generated<string>;
|
|
||||||
webhookId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
event: string;
|
|
||||||
payload: Json;
|
|
||||||
status: Generated<string>;
|
|
||||||
httpStatus: number | null;
|
|
||||||
responseBody: string | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
attemptCount: Generated<number>;
|
|
||||||
durationMs: number | null;
|
|
||||||
deliveredAt: Timestamp | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
aiChats: AiChats;
|
aiChats: AiChats;
|
||||||
aiChatMessages: AiChatMessages;
|
aiChatMessages: AiChatMessages;
|
||||||
@@ -656,8 +625,6 @@ export interface DB {
|
|||||||
userSessions: UserSessions;
|
userSessions: UserSessions;
|
||||||
userTokens: UserTokens;
|
userTokens: UserTokens;
|
||||||
watchers: Watchers;
|
watchers: Watchers;
|
||||||
webhooks: Webhooks;
|
|
||||||
webhookDeliveries: WebhookDeliveries;
|
|
||||||
workspaceInvitations: WorkspaceInvitations;
|
workspaceInvitations: WorkspaceInvitations;
|
||||||
workspaces: Workspaces;
|
workspaces: Workspaces;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ import {
|
|||||||
Watchers,
|
Watchers,
|
||||||
Audit as _Audit,
|
Audit as _Audit,
|
||||||
Templates,
|
Templates,
|
||||||
Webhooks,
|
|
||||||
WebhookDeliveries,
|
|
||||||
} from './db';
|
} from './db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
|
|
||||||
@@ -240,13 +238,3 @@ export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
|||||||
export type Template = Selectable<Templates>;
|
export type Template = Selectable<Templates>;
|
||||||
export type InsertableTemplate = Insertable<Templates>;
|
export type InsertableTemplate = Insertable<Templates>;
|
||||||
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
||||||
|
|
||||||
// Webhook
|
|
||||||
export type Webhook = Selectable<Webhooks>;
|
|
||||||
export type InsertableWebhook = Insertable<Webhooks>;
|
|
||||||
export type UpdatableWebhook = Updateable<Omit<Webhooks, 'id'>>;
|
|
||||||
|
|
||||||
// Webhook delivery
|
|
||||||
export type WebhookDelivery = Selectable<WebhookDeliveries>;
|
|
||||||
export type InsertableWebhookDelivery = Insertable<WebhookDeliveries>;
|
|
||||||
export type UpdatableWebhookDelivery = Updateable<Omit<WebhookDeliveries, 'id'>>;
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 190deffa98...71844c0972
@@ -9,7 +9,6 @@ export enum QueueName {
|
|||||||
HISTORY_QUEUE = '{history-queue}',
|
HISTORY_QUEUE = '{history-queue}',
|
||||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||||
AUDIT_QUEUE = '{audit-queue}',
|
AUDIT_QUEUE = '{audit-queue}',
|
||||||
WEBHOOK_QUEUE = '{webhook-queue}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
@@ -84,7 +83,4 @@ export enum QueueJob {
|
|||||||
|
|
||||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||||
|
|
||||||
WEBHOOK_DELIVERY = 'webhook-delivery',
|
|
||||||
WEBHOOK_DELIVERY_CLEANUP = 'webhook-delivery-cleanup',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
|||||||
attempts: 3,
|
attempts: 3,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.WEBHOOK_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
attempts: 5,
|
|
||||||
backoff: {
|
|
||||||
type: 'exponential',
|
|
||||||
delay: 10 * 1000,
|
|
||||||
},
|
|
||||||
removeOnComplete: { count: 200 },
|
|
||||||
removeOnFail: { count: 200 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
providers: [GeneralQueueProcessor],
|
providers: [GeneralQueueProcessor],
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Section, Text } from 'react-email';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { content, paragraph } from '../css/styles';
|
|
||||||
import { EmailButton, MailBody, getGreetingName } from '../partials/partials';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
recipientName?: string;
|
|
||||||
webhookName: string;
|
|
||||||
webhookUrl: string;
|
|
||||||
settingsUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WebhookDisabledEmail = ({
|
|
||||||
recipientName,
|
|
||||||
webhookName,
|
|
||||||
webhookUrl,
|
|
||||||
settingsUrl,
|
|
||||||
}: Props) => {
|
|
||||||
return (
|
|
||||||
<MailBody>
|
|
||||||
<Section style={content}>
|
|
||||||
<Text style={paragraph}>Hi {getGreetingName(recipientName)},</Text>
|
|
||||||
<Text style={paragraph}>
|
|
||||||
Your webhook <strong>{webhookName}</strong> to{' '}
|
|
||||||
<strong>{webhookUrl}</strong> has been disabled after too many
|
|
||||||
consecutive delivery failures.
|
|
||||||
</Text>
|
|
||||||
<Text style={paragraph}>
|
|
||||||
Re-enable it in your workspace settings once the receiving endpoint
|
|
||||||
is healthy again.
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
<EmailButton href={settingsUrl}>Open webhook settings</EmailButton>
|
|
||||||
</MailBody>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WebhookDisabledEmail;
|
|
||||||
@@ -31,6 +31,5 @@ export * from "./lib/recreate-transform";
|
|||||||
export * from "./lib/columns";
|
export * from "./lib/columns";
|
||||||
export * from "./lib/status";
|
export * from "./lib/status";
|
||||||
export * from "./lib/pdf";
|
export * from "./lib/pdf";
|
||||||
export * from "./lib/page-break";
|
|
||||||
export * from "./lib/resizable-nodeview";
|
export * from "./lib/resizable-nodeview";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
|
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
|
||||||
import CodeBlock from '@tiptap/extension-code-block';
|
import CodeBlock from '@tiptap/extension-code-block';
|
||||||
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
|
|
||||||
import { GapCursor } from '@tiptap/pm/gapcursor';
|
|
||||||
|
|
||||||
import { LowlightPlugin } from './lowlight-plugin.js';
|
import { LowlightPlugin } from './lowlight-plugin.js';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
@@ -21,11 +19,7 @@ const TAB_CHAR = '\u00A0\u00A0';
|
|||||||
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
||||||
*/
|
*/
|
||||||
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||||
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
|
|
||||||
// can intercept before gapcursor takes over.
|
|
||||||
priority: 101,
|
|
||||||
selectable: true,
|
selectable: true,
|
||||||
isolating: true,
|
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
@@ -41,86 +35,8 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
const isMermaid = (node: any) =>
|
|
||||||
node?.type === this.type && node.attrs.language === 'mermaid';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
// Stop at the gap (or enter mermaid source) instead of jumping
|
|
||||||
// straight into the next block, so the user can place a cursor
|
|
||||||
// between two adjacent isolating blocks.
|
|
||||||
ArrowDown: ({ editor }) => {
|
|
||||||
const { state } = editor;
|
|
||||||
const { selection, doc } = state;
|
|
||||||
const { $from, empty } = selection;
|
|
||||||
|
|
||||||
if (!empty || $from.parent.type !== this.type) return false;
|
|
||||||
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
|
||||||
|
|
||||||
const after = $from.after();
|
|
||||||
if (after >= doc.content.size) {
|
|
||||||
return editor.commands.exitCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
const $after = doc.resolve(after);
|
|
||||||
const nodeAfter = $after.nodeAfter;
|
|
||||||
|
|
||||||
if (isMermaid(nodeAfter)) {
|
|
||||||
return editor.commands.command(({ tr }) => {
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
nodeAfter?.type.spec.isolating &&
|
|
||||||
!nodeAfter.type.spec.atom
|
|
||||||
) {
|
|
||||||
return editor.commands.command(({ tr }) => {
|
|
||||||
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.commands.command(({ tr }) => {
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(after)));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Mirror of ArrowDown; upstream has no ArrowUp handler.
|
|
||||||
ArrowUp: ({ editor }) => {
|
|
||||||
const { state } = editor;
|
|
||||||
const { selection, doc } = state;
|
|
||||||
const { $from, empty } = selection;
|
|
||||||
|
|
||||||
if (!empty || $from.parent.type !== this.type) return false;
|
|
||||||
if ($from.parentOffset !== 0) return false;
|
|
||||||
|
|
||||||
const before = $from.before();
|
|
||||||
if (before <= 0) return false;
|
|
||||||
|
|
||||||
const $before = doc.resolve(before);
|
|
||||||
const nodeBefore = $before.nodeBefore;
|
|
||||||
|
|
||||||
if (isMermaid(nodeBefore)) {
|
|
||||||
return editor.commands.command(({ tr }) => {
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, before - 1));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
nodeBefore?.type.spec.isolating &&
|
|
||||||
!nodeBefore.type.spec.atom
|
|
||||||
) {
|
|
||||||
return editor.commands.command(({ tr }) => {
|
|
||||||
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
'Mod-a': () => {
|
'Mod-a': () => {
|
||||||
if (this.editor.isActive('codeBlock')) {
|
if (this.editor.isActive('codeBlock')) {
|
||||||
const { state } = this.editor;
|
const { state } = this.editor;
|
||||||
@@ -168,7 +84,6 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const codeBlockType = this.type;
|
|
||||||
return [
|
return [
|
||||||
...(this.parent?.() || []),
|
...(this.parent?.() || []),
|
||||||
LowlightPlugin({
|
LowlightPlugin({
|
||||||
@@ -176,60 +91,6 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
|||||||
lowlight: this.options.lowlight,
|
lowlight: this.options.lowlight,
|
||||||
defaultLanguage: this.options.defaultLanguage,
|
defaultLanguage: this.options.defaultLanguage,
|
||||||
}),
|
}),
|
||||||
// Mermaid hides its <pre> when unselected, so the browser's native
|
|
||||||
// vertical caret movement skips past it. Land the cursor inside the
|
|
||||||
// source explicitly.
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
handleKeyDown: (view, event) => {
|
|
||||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { state } = view;
|
|
||||||
const { selection } = state;
|
|
||||||
if (
|
|
||||||
!selection.empty ||
|
|
||||||
!(selection instanceof TextSelection)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { $from } = selection;
|
|
||||||
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
|
||||||
if (!view.endOfTextblock(dir)) return false;
|
|
||||||
|
|
||||||
const isMermaid = (node: any) =>
|
|
||||||
node?.type === codeBlockType && node.attrs.language === 'mermaid';
|
|
||||||
|
|
||||||
if (event.key === 'ArrowUp') {
|
|
||||||
if ($from.parentOffset !== 0) return false;
|
|
||||||
const beforePos = $from.before();
|
|
||||||
const prev = state.doc.resolve(beforePos).nodeBefore;
|
|
||||||
if (!isMermaid(prev)) return false;
|
|
||||||
const endPos = beforePos - 1;
|
|
||||||
view.dispatch(
|
|
||||||
state.tr.setSelection(
|
|
||||||
TextSelection.create(state.doc, endPos),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
|
||||||
const afterPos = $from.after();
|
|
||||||
const next = state.doc.resolve(afterPos).nodeAfter;
|
|
||||||
if (!isMermaid(next)) return false;
|
|
||||||
const startPos = afterPos + 1;
|
|
||||||
view.dispatch(
|
|
||||||
state.tr.setSelection(
|
|
||||||
TextSelection.create(state.doc, startPos),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./page-break";
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { mergeAttributes, Node } from "@tiptap/core";
|
|
||||||
|
|
||||||
export interface PageBreakOptions {
|
|
||||||
HTMLAttributes: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
pageBreak: {
|
|
||||||
setPageBreak: () => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageBreak = Node.create<PageBreakOptions>({
|
|
||||||
name: "pageBreak",
|
|
||||||
|
|
||||||
group: "block",
|
|
||||||
|
|
||||||
atom: true,
|
|
||||||
|
|
||||||
selectable: true,
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
HTMLAttributes: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: `div[data-type="${this.name}"]`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
return [
|
|
||||||
"div",
|
|
||||||
mergeAttributes(
|
|
||||||
{ "data-type": this.name, class: "page-break" },
|
|
||||||
this.options.HTMLAttributes,
|
|
||||||
HTMLAttributes,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setPageBreak:
|
|
||||||
() =>
|
|
||||||
({ chain }) =>
|
|
||||||
chain()
|
|
||||||
.insertContent({ type: this.name })
|
|
||||||
.focus()
|
|
||||||
.run(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -338,15 +338,6 @@ export const isRowGripSelected = ({
|
|||||||
return !!gripRow;
|
return !!gripRow;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TipTap's `editor.view` proxy throws if accessed before mount or after destroy.
|
|
||||||
// Guard floating-menu callbacks (getReferencedVirtualElement, shouldShow) with
|
|
||||||
// this before touching `editor.view.nodeDOM(...)`.
|
|
||||||
export function isEditorReady(
|
|
||||||
editor: Editor | null | undefined,
|
|
||||||
): editor is Editor {
|
|
||||||
return !!editor && editor.isInitialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTextSelected(editor: Editor) {
|
export function isTextSelected(editor: Editor) {
|
||||||
const {
|
const {
|
||||||
state: {
|
state: {
|
||||||
|
|||||||
Generated
+15
-15
@@ -637,14 +637,14 @@ importers:
|
|||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
kysely:
|
kysely:
|
||||||
specifier: ^0.28.17
|
specifier: ^0.29.0
|
||||||
version: 0.28.17
|
version: 0.29.0
|
||||||
kysely-migration-cli:
|
kysely-migration-cli:
|
||||||
specifier: ^0.4.2
|
specifier: ^0.4.2
|
||||||
version: 0.4.2
|
version: 0.4.2
|
||||||
kysely-postgres-js:
|
kysely-postgres-js:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0(kysely@0.28.17)(postgres@3.4.8)
|
version: 3.0.0(kysely@0.29.0)(postgres@3.4.8)
|
||||||
ldapts:
|
ldapts:
|
||||||
specifier: ^8.1.7
|
specifier: ^8.1.7
|
||||||
version: 8.1.7
|
version: 8.1.7
|
||||||
@@ -668,7 +668,7 @@ importers:
|
|||||||
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
nestjs-kysely:
|
nestjs-kysely:
|
||||||
specifier: ^3.1.2
|
specifier: ^3.1.2
|
||||||
version: 3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.28.17)(reflect-metadata@0.2.2)
|
version: 3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.29.0)(reflect-metadata@0.2.2)
|
||||||
nestjs-pino:
|
nestjs-pino:
|
||||||
specifier: ^4.6.1
|
specifier: ^4.6.1
|
||||||
version: 4.6.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
|
version: 4.6.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
|
||||||
@@ -819,7 +819,7 @@ importers:
|
|||||||
version: 30.3.0(@types/node@25.5.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.0)(typescript@5.9.3))
|
version: 30.3.0(@types/node@25.5.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.0)(typescript@5.9.3))
|
||||||
kysely-codegen:
|
kysely-codegen:
|
||||||
specifier: ^0.20.0
|
specifier: ^0.20.0
|
||||||
version: 0.20.0(kysely@0.28.17)(pg@8.16.3)(typescript@5.9.3)
|
version: 0.20.0(kysely@0.29.0)(pg@8.16.3)(typescript@5.9.3)
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
@@ -7815,9 +7815,9 @@ packages:
|
|||||||
postgres:
|
postgres:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
kysely@0.28.17:
|
kysely@0.29.0:
|
||||||
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
|
resolution: {integrity: sha512-LrQfPUeTW7MXbMvT62moEMnpMTuj9TO3lqjCeLKjM975PJ4Alrl/43f2tlDX7xOsNptKgH4LSNGwIbXwEkLg4g==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=22.0.0'}
|
||||||
|
|
||||||
langium@3.3.1:
|
langium@3.3.1:
|
||||||
resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==}
|
resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==}
|
||||||
@@ -18686,14 +18686,14 @@ snapshots:
|
|||||||
|
|
||||||
klona@2.0.6: {}
|
klona@2.0.6: {}
|
||||||
|
|
||||||
kysely-codegen@0.20.0(kysely@0.28.17)(pg@8.16.3)(typescript@5.9.3):
|
kysely-codegen@0.20.0(kysely@0.29.0)(pg@8.16.3)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cosmiconfig: 9.0.0(typescript@5.9.3)
|
cosmiconfig: 9.0.0(typescript@5.9.3)
|
||||||
diff: 8.0.3
|
diff: 8.0.3
|
||||||
dotenv: 17.2.4
|
dotenv: 17.2.4
|
||||||
dotenv-expand: 12.0.3
|
dotenv-expand: 12.0.3
|
||||||
kysely: 0.28.17
|
kysely: 0.29.0
|
||||||
micromatch: 4.0.8
|
micromatch: 4.0.8
|
||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
pluralize: 8.0.0
|
pluralize: 8.0.0
|
||||||
@@ -18708,13 +18708,13 @@ snapshots:
|
|||||||
'@commander-js/extra-typings': 11.1.0(commander@11.1.0)
|
'@commander-js/extra-typings': 11.1.0(commander@11.1.0)
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
|
|
||||||
kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.8):
|
kysely-postgres-js@3.0.0(kysely@0.29.0)(postgres@3.4.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
kysely: 0.28.17
|
kysely: 0.29.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
|
|
||||||
kysely@0.28.17: {}
|
kysely@0.29.0: {}
|
||||||
|
|
||||||
langium@3.3.1:
|
langium@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -19142,11 +19142,11 @@ snapshots:
|
|||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
|
|
||||||
nestjs-kysely@3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.28.17)(reflect-metadata@0.2.2):
|
nestjs-kysely@3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.29.0)(reflect-metadata@0.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
kysely: 0.28.17
|
kysely: 0.29.0
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
nestjs-pino@4.6.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2):
|
nestjs-pino@4.6.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2):
|
||||||
|
|||||||
Reference in New Issue
Block a user