mirror of
https://github.com/docmost/docmost.git
synced 2026-05-22 01:32:55 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 834b6f68f7 | |||
| 214daa1ec3 | |||
| 6af74eb3d4 | |||
| e7fff3c9b5 | |||
| 63c1241125 | |||
| 0ae407839f | |||
| d524073d86 | |||
| 5c5fff517c | |||
| 3115c5e097 |
@@ -39,6 +39,7 @@ 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";
|
||||||
@@ -46,7 +47,6 @@ import FavoritesPage from "@/pages/favorites/favorites-page";
|
|||||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||||
import LabelPage from "@/pages/label/label-page";
|
import LabelPage from "@/pages/label/label-page";
|
||||||
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -125,11 +125,8 @@ 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 />} />
|
||||||
<Route
|
|
||||||
path={"import/confluence"}
|
|
||||||
element={<ConfluenceImportPage />}
|
|
||||||
/>
|
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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: "" };
|
||||||
@@ -106,3 +107,11 @@ 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,7 @@ import {
|
|||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconFileImport,
|
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,6 +39,7 @@ 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,10 +128,10 @@ const groupedData: DataGroup[] = [
|
|||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Import",
|
label: "Webhooks",
|
||||||
icon: IconFileImport,
|
icon: IconWebhook,
|
||||||
path: "/settings/import/confluence",
|
path: "/settings/webhooks",
|
||||||
feature: Feature.CONFLUENCE_IMPORT,
|
feature: Feature.WEBHOOKS,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -230,6 +231,9 @@ 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;
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Progress,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconAlertCircle, IconCheck, IconX } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ConfluenceImportHistoryItem,
|
|
||||||
ConfluenceImportStatus,
|
|
||||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import { useConfluenceImportsQuery } from "@/ee/confluence-import/queries/confluence-import-queries";
|
|
||||||
|
|
||||||
const BADGE_STYLES = {
|
|
||||||
root: { flexShrink: 0 },
|
|
||||||
label: { overflow: "visible" as const },
|
|
||||||
};
|
|
||||||
|
|
||||||
function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
|
|
||||||
if (cancelled) {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconX size={12} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
Cancelled
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === "processing") {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<Loader size={10} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
Running
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === "success") {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="teal"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconCheck size={12} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
Completed
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconAlertCircle size={12} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
Failed
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function phaseLabel(phase: string | null): string {
|
|
||||||
if (!phase) return "—";
|
|
||||||
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function progressValue(item: ConfluenceImportHistoryItem) {
|
|
||||||
if (item.status === "success") return 100;
|
|
||||||
if (item.totalPages > 0) {
|
|
||||||
return Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((item.importedPages / item.totalPages) * 100),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return item.status === "processing" ? 5 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
|
|
||||||
const value = progressValue(item);
|
|
||||||
const color =
|
|
||||||
item.status === "failed"
|
|
||||||
? "red"
|
|
||||||
: item.status === "success"
|
|
||||||
? "teal"
|
|
||||||
: "blue";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Progress value={value} color={color} size="xs" animated={item.status === "processing"} />
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{item.importedPages}/{item.totalPages || "?"} pages
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
· {item.importedSpaces}/{item.totalSpaces || "?"} spaces
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
· {item.importedUsers}/{item.totalUsers || "?"} users
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<Table.Tr key={i}>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={180} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={80} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={140} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfluenceImportHistory() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading } = useConfluenceImportsQuery();
|
|
||||||
|
|
||||||
const items = useMemo(() => data?.items ?? [], [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.ScrollContainer minWidth={720}>
|
|
||||||
<Table verticalSpacing="xs" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
|
||||||
<Table.Th>{t("Site")}</Table.Th>
|
|
||||||
<Table.Th>{t("Phase")}</Table.Th>
|
|
||||||
<Table.Th>{t("Progress")}</Table.Th>
|
|
||||||
<Table.Th>{t("Started by")}</Table.Th>
|
|
||||||
<Table.Th>{t("Started at")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton />
|
|
||||||
) : items.length > 0 ? (
|
|
||||||
items.map((item) => (
|
|
||||||
<Table.Tr key={item.fileTaskId}>
|
|
||||||
<Table.Td>
|
|
||||||
{statusBadge(item.status, item.cancelled)}
|
|
||||||
{item.status === "failed" && item.errorMessage && (
|
|
||||||
<Tooltip label={item.errorMessage} multiline w={320}>
|
|
||||||
<Text fz="xs" c="red" lineClamp={1} maw={180}>
|
|
||||||
{item.errorMessage}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" lineClamp={1} maw={240}>
|
|
||||||
{item.siteUrl}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{phaseLabel(item.currentPhase)}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<ProgressCell item={item} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
{item.creatorName ? (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={item.creatorAvatarUrl}
|
|
||||||
name={item.creatorName}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
<Text fz="sm" lineClamp={1}>
|
|
||||||
{item.creatorName}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formattedDate(new Date(item.createdAt))}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={6} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
PasswordInput,
|
|
||||||
ScrollArea,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
Stepper,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconCheck,
|
|
||||||
IconCloudCheck,
|
|
||||||
IconPlug,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
listConfluenceSpaces,
|
|
||||||
startConfluenceImport,
|
|
||||||
testConfluenceConnection,
|
|
||||||
} from "@/ee/confluence-import/services/confluence-import-service";
|
|
||||||
import {
|
|
||||||
ConfluenceAuthType,
|
|
||||||
ConfluenceCredentials,
|
|
||||||
ConfluenceSpaceSummary,
|
|
||||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
|
||||||
import { confluenceImportsQueryKey } from "@/ee/confluence-import/queries/confluence-import-queries";
|
|
||||||
|
|
||||||
type ConfluenceEditionChoice = "cloud" | "server";
|
|
||||||
|
|
||||||
type CredentialsFormValues = {
|
|
||||||
edition: ConfluenceEditionChoice;
|
|
||||||
authType: ConfluenceAuthType;
|
|
||||||
siteUrl: string;
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConfluenceImportModal({ opened, onClose }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [spaces, setSpaces] = useState<ConfluenceSpaceSummary[]>([]);
|
|
||||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
|
||||||
const [importAll, setImportAll] = useState(true);
|
|
||||||
|
|
||||||
const form = useForm<CredentialsFormValues>({
|
|
||||||
initialValues: {
|
|
||||||
edition: "server",
|
|
||||||
authType: "pat",
|
|
||||||
siteUrl: "",
|
|
||||||
email: "",
|
|
||||||
token: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
validate: {
|
|
||||||
siteUrl: (value) =>
|
|
||||||
!value?.trim()
|
|
||||||
? t("Site URL is required")
|
|
||||||
: !/^https?:\/\//i.test(value.trim())
|
|
||||||
? t("Site URL must start with http:// or https://")
|
|
||||||
: null,
|
|
||||||
email: (value, values) =>
|
|
||||||
values.edition === "cloud" && !value?.trim()
|
|
||||||
? t("Email is required")
|
|
||||||
: null,
|
|
||||||
token: (value, values) =>
|
|
||||||
(values.authType === "cloud_token" || values.authType === "pat") &&
|
|
||||||
!value?.trim()
|
|
||||||
? t("API token is required")
|
|
||||||
: null,
|
|
||||||
username: (value, values) =>
|
|
||||||
values.authType === "basic" && !value?.trim()
|
|
||||||
? t("Username is required")
|
|
||||||
: null,
|
|
||||||
password: (value, values) =>
|
|
||||||
values.authType === "basic" && !value?.trim()
|
|
||||||
? t("Password is required")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) {
|
|
||||||
setActive(0);
|
|
||||||
setError(null);
|
|
||||||
setSpaces([]);
|
|
||||||
setSelectedKeys([]);
|
|
||||||
setImportAll(true);
|
|
||||||
setLoading(false);
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [opened]);
|
|
||||||
|
|
||||||
const credentials: ConfluenceCredentials = useMemo(() => {
|
|
||||||
const values = form.values;
|
|
||||||
return {
|
|
||||||
siteUrl: values.siteUrl.trim().replace(/\/+$/, ""),
|
|
||||||
authType: values.authType,
|
|
||||||
email: values.email?.trim() || undefined,
|
|
||||||
token: values.token?.trim() || undefined,
|
|
||||||
username: values.username?.trim() || undefined,
|
|
||||||
password: values.password || undefined,
|
|
||||||
};
|
|
||||||
}, [form.values]);
|
|
||||||
|
|
||||||
const handleEditionChange = (edition: ConfluenceEditionChoice) => {
|
|
||||||
form.setFieldValue("edition", edition);
|
|
||||||
if (edition === "cloud") {
|
|
||||||
form.setFieldValue("authType", "cloud_token");
|
|
||||||
} else if (form.values.authType === "cloud_token") {
|
|
||||||
form.setFieldValue("authType", "pat");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextFromCredentials = async () => {
|
|
||||||
if (form.validate().hasErrors) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const test = await testConfluenceConnection(credentials);
|
|
||||||
if (!test.success) {
|
|
||||||
setError(test.error || t("Connection failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = await listConfluenceSpaces(credentials);
|
|
||||||
if (!list.success || !list.spaces) {
|
|
||||||
setError(list.error || t("Failed to load spaces"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSpaces(list.spaces);
|
|
||||||
setSelectedKeys(list.spaces.map((s) => s.key));
|
|
||||||
setImportAll(true);
|
|
||||||
setActive(1);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.message || err?.message || t("Unexpected error"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSpace = (key: string, checked: boolean) => {
|
|
||||||
setSelectedKeys((prev) =>
|
|
||||||
checked ? Array.from(new Set([...prev, key])) : prev.filter((k) => k !== key),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = (checked: boolean) => {
|
|
||||||
setImportAll(checked);
|
|
||||||
setSelectedKeys(checked ? spaces.map((s) => s.key) : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartImport = async () => {
|
|
||||||
const spaceKeys = importAll ? [] : selectedKeys;
|
|
||||||
if (!importAll && spaceKeys.length === 0) {
|
|
||||||
setError(t("Select at least one space to import"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await startConfluenceImport({
|
|
||||||
...credentials,
|
|
||||||
spaceKeys,
|
|
||||||
});
|
|
||||||
if (!result.success || !result.fileTaskId) {
|
|
||||||
setError(result.error || t("Failed to start import"));
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: confluenceImportsQueryKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
title: t("Confluence import started"),
|
|
||||||
message: t("Track progress below. This runs in the background."),
|
|
||||||
color: "blue",
|
|
||||||
icon: <IconCheck size={18} />,
|
|
||||||
autoClose: 4000,
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(
|
|
||||||
err?.response?.data?.message || err?.message || t("Unexpected error"),
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelFlow = async () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const editionSegment = (
|
|
||||||
<SegmentedControl
|
|
||||||
value={form.values.edition}
|
|
||||||
onChange={(val) => handleEditionChange(val as ConfluenceEditionChoice)}
|
|
||||||
data={[
|
|
||||||
{ value: "server", label: t("Data Center / Server") },
|
|
||||||
{ value: "cloud", label: t("Cloud") },
|
|
||||||
]}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const authTypeSegment = form.values.edition === "server" && (
|
|
||||||
<SegmentedControl
|
|
||||||
value={form.values.authType}
|
|
||||||
onChange={(val) =>
|
|
||||||
form.setFieldValue("authType", val as ConfluenceAuthType)
|
|
||||||
}
|
|
||||||
data={[
|
|
||||||
{ value: "pat", label: t("Personal Access Token") },
|
|
||||||
{ value: "basic", label: t("Username + password") },
|
|
||||||
]}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedCount = importAll ? spaces.length : selectedKeys.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Import from Confluence")}
|
|
||||||
size={720}
|
|
||||||
centered
|
|
||||||
closeOnClickOutside={!loading}
|
|
||||||
closeOnEscape={!loading}
|
|
||||||
>
|
|
||||||
<Stepper active={active} size="sm" mb="md" allowNextStepsSelect={false}>
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Connect")}
|
|
||||||
description={t("Credentials")}
|
|
||||||
icon={<IconPlug size={18} />}
|
|
||||||
/>
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Select spaces")}
|
|
||||||
description={t("Choose what to import")}
|
|
||||||
icon={<IconCloudCheck size={18} />}
|
|
||||||
/>
|
|
||||||
</Stepper>
|
|
||||||
|
|
||||||
{active === 0 && (
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Enter your Confluence URL and credentials. We'll validate the connection before continuing.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
{editionSegment}
|
|
||||||
{authTypeSegment}
|
|
||||||
<TextInput
|
|
||||||
label={t("Site URL")}
|
|
||||||
placeholder={
|
|
||||||
form.values.edition === "cloud"
|
|
||||||
? "https://your-site.atlassian.net/wiki"
|
|
||||||
: "https://confluence.example.com"
|
|
||||||
}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("siteUrl")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.values.edition === "cloud" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t("Email")}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
required
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t("API token")}
|
|
||||||
description={t(
|
|
||||||
"Create at id.atlassian.com/manage-profile/security/api-tokens",
|
|
||||||
)}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("token")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.values.edition === "server" &&
|
|
||||||
form.values.authType === "pat" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t("Email")}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Personal Access Token")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("token")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.values.edition === "server" &&
|
|
||||||
form.values.authType === "basic" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t("Username")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("username")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Password")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label={t("Email (optional)")}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={handleCancelFlow} disabled={loading}>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleNextFromCredentials}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t("Test & continue")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === 1 && (
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Choose the spaces to import. Users, groups and permissions will be imported for the selected spaces.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label={t("Import all spaces ({{count}})", {
|
|
||||||
count: spaces.length,
|
|
||||||
})}
|
|
||||||
checked={importAll}
|
|
||||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollArea h={320} type="auto" offsetScrollbars>
|
|
||||||
<Stack gap="xs">
|
|
||||||
{spaces.map((space) => (
|
|
||||||
<Checkbox
|
|
||||||
key={space.id}
|
|
||||||
label={
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Text fw={500}>{space.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
({space.key})
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
checked={importAll || selectedKeys.includes(space.key)}
|
|
||||||
disabled={importAll}
|
|
||||||
onChange={(e) =>
|
|
||||||
toggleSpace(space.key, e.currentTarget.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{spaces.length === 0 && (
|
|
||||||
<Text c="dimmed" ta="center" py="lg">
|
|
||||||
{t("No spaces found for this account.")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("{{count}} selected", { count: selectedCount })}
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setActive(0)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("Back")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleStartImport}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!importAll && selectedKeys.length === 0}
|
|
||||||
>
|
|
||||||
{t("Start import")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon";
|
|
||||||
import ConfluenceImportModal from "@/ee/confluence-import/components/confluence-import-modal";
|
|
||||||
import ConfluenceImportHistory from "@/ee/confluence-import/components/confluence-import-history";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
|
|
||||||
export default function ConfluenceImportPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
const hasConfluenceImport = useHasFeature(Feature.CONFLUENCE_IMPORT);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Import from Confluence")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("Import from Confluence")} />
|
|
||||||
|
|
||||||
<Paper withBorder p="lg" radius="md" mb="lg">
|
|
||||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
|
||||||
<Group align="flex-start" wrap="nowrap">
|
|
||||||
<ConfluenceIcon size={32} />
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text fw={600}>{t("Confluence API import")}</Text>
|
|
||||||
<Text size="sm" c="dimmed" maw={560}>
|
|
||||||
{t(
|
|
||||||
"Connect to Confluence Cloud or Data Center to import spaces, pages, attachments, comments, users, groups and permissions directly via the API.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasConfluenceImport}>
|
|
||||||
<Button onClick={open} disabled={!hasConfluenceImport}>
|
|
||||||
{t("Start import")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Divider my="md" label={t("Import history")} labelPosition="left" />
|
|
||||||
|
|
||||||
<ConfluenceImportHistory />
|
|
||||||
|
|
||||||
<ConfluenceImportModal opened={opened} onClose={close} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { listConfluenceImports } from "@/ee/confluence-import/services/confluence-import-service";
|
|
||||||
|
|
||||||
export const confluenceImportsQueryKey = ["confluence-imports"] as const;
|
|
||||||
|
|
||||||
export function useConfluenceImportsQuery() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: confluenceImportsQueryKey,
|
|
||||||
queryFn: listConfluenceImports,
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const hasRunning = query.state.data?.items?.some(
|
|
||||||
(i) => i.status === "processing",
|
|
||||||
);
|
|
||||||
return hasRunning ? 3000 : false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
ConfluenceCredentials,
|
|
||||||
ImportStatusResponse,
|
|
||||||
ListImportsResponse,
|
|
||||||
ListSpacesResponse,
|
|
||||||
StartImportResponse,
|
|
||||||
TestConnectionResponse,
|
|
||||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
|
||||||
|
|
||||||
export async function testConfluenceConnection(
|
|
||||||
data: ConfluenceCredentials,
|
|
||||||
): Promise<TestConnectionResponse> {
|
|
||||||
const req = await api.post<TestConnectionResponse>(
|
|
||||||
"/confluence-import/test-connection",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listConfluenceSpaces(
|
|
||||||
data: ConfluenceCredentials,
|
|
||||||
): Promise<ListSpacesResponse> {
|
|
||||||
const req = await api.post<ListSpacesResponse>(
|
|
||||||
"/confluence-import/spaces",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startConfluenceImport(
|
|
||||||
data: ConfluenceCredentials & { spaceKeys?: string[] },
|
|
||||||
): Promise<StartImportResponse> {
|
|
||||||
const req = await api.post<StartImportResponse>(
|
|
||||||
"/confluence-import/start",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConfluenceImportStatus(
|
|
||||||
fileTaskId: string,
|
|
||||||
): Promise<ImportStatusResponse> {
|
|
||||||
const req = await api.post<ImportStatusResponse>(
|
|
||||||
"/confluence-import/status",
|
|
||||||
{ fileTaskId },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listConfluenceImports(): Promise<ListImportsResponse> {
|
|
||||||
const req = await api.post<ListImportsResponse>("/confluence-import/history");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelConfluenceImport(
|
|
||||||
fileTaskId: string,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const req = await api.post<{ success: boolean }>(
|
|
||||||
"/confluence-import/cancel",
|
|
||||||
{ fileTaskId },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
export type ConfluenceAuthType = "cloud_token" | "pat" | "basic";
|
|
||||||
|
|
||||||
export type ConfluenceCredentials = {
|
|
||||||
siteUrl: string;
|
|
||||||
authType: ConfluenceAuthType;
|
|
||||||
email?: string;
|
|
||||||
token?: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfluenceSpaceSummary = {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type?: string;
|
|
||||||
status?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TestConnectionResponse = {
|
|
||||||
success: boolean;
|
|
||||||
edition?: string;
|
|
||||||
spaceCount?: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListSpacesResponse = {
|
|
||||||
success: boolean;
|
|
||||||
spaces?: ConfluenceSpaceSummary[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartImportResponse = {
|
|
||||||
success: boolean;
|
|
||||||
fileTaskId?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfluenceImportStatus = "processing" | "success" | "failed";
|
|
||||||
|
|
||||||
export type ImportStatusResponse = {
|
|
||||||
fileTaskId?: string;
|
|
||||||
status?: ConfluenceImportStatus;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
currentPhase?: string | null;
|
|
||||||
totalSpaces?: number;
|
|
||||||
importedSpaces?: number;
|
|
||||||
totalPages?: number;
|
|
||||||
importedPages?: number;
|
|
||||||
totalUsers?: number;
|
|
||||||
importedUsers?: number;
|
|
||||||
warnings?: string[];
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfluenceImportHistoryItem = {
|
|
||||||
fileTaskId: string;
|
|
||||||
siteUrl: string;
|
|
||||||
status: ConfluenceImportStatus;
|
|
||||||
errorMessage: string | null;
|
|
||||||
currentPhase: string | null;
|
|
||||||
totalSpaces: number;
|
|
||||||
importedSpaces: number;
|
|
||||||
totalPages: number;
|
|
||||||
importedPages: number;
|
|
||||||
totalUsers: number;
|
|
||||||
importedUsers: number;
|
|
||||||
cancelled: boolean;
|
|
||||||
spaceKeys: string[];
|
|
||||||
warnings: string[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
creatorName: string | null;
|
|
||||||
creatorAvatarUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListImportsResponse = {
|
|
||||||
items: ConfluenceImportHistoryItem[];
|
|
||||||
};
|
|
||||||
@@ -19,4 +19,5 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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 })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -166,18 +166,6 @@ export default function useAuth() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setCurrentUser(RESET);
|
setCurrentUser(RESET);
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof indexedDB?.databases === "function") {
|
|
||||||
const dbs = await indexedDB.databases();
|
|
||||||
dbs
|
|
||||||
.filter((db) => db.name?.startsWith("page."))
|
|
||||||
.forEach((db) => indexedDB.deleteDatabase(db.name!));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
|
||||||
import { Color } from "@tiptap/extension-color";
|
|
||||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
@@ -49,8 +47,6 @@ const CommentEditor = forwardRef(
|
|||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
TextStyle,
|
|
||||||
Color,
|
|
||||||
EmojiCommand,
|
EmojiCommand,
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ['docmost.nz'],
|
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: APP_URL,
|
target: APP_URL,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { LoggerModule } from './common/logger/logger.module';
|
|||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +53,6 @@ try {
|
|||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
EncryptionModule,
|
|
||||||
RedisModule.forRootAsync({
|
RedisModule.forRootAsync({
|
||||||
useClass: RedisConfigService,
|
useClass: RedisConfigService,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ 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 {
|
||||||
@@ -47,6 +49,7 @@ 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) {
|
||||||
@@ -199,6 +202,19 @@ 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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];
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ 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 {
|
||||||
@@ -39,6 +41,7 @@ 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: {
|
||||||
@@ -271,7 +274,7 @@ export class AttachmentService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
trx,
|
trx,
|
||||||
} = opts;
|
} = opts;
|
||||||
return this.attachmentRepo.insertAttachment(
|
const attachment = await this.attachmentRepo.insertAttachment(
|
||||||
{
|
{
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -287,6 +290,23 @@ 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,6 +42,7 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ 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,6 +13,7 @@ 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 =
|
||||||
@@ -22,4 +23,5 @@ 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,6 +32,8 @@ 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')
|
||||||
@@ -44,6 +46,7 @@ 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)
|
||||||
@@ -192,5 +195,16 @@ export class CommentController {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.webhookDispatcher.dispatch(
|
||||||
|
comment.workspaceId,
|
||||||
|
WebhookEvent.CommentDeleted,
|
||||||
|
{
|
||||||
|
id: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
workspaceId: comment.workspaceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ 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 {
|
||||||
@@ -33,6 +35,7 @@ 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) {
|
||||||
@@ -142,6 +145,21 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +221,22 @@ 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,6 +52,8 @@ 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')
|
||||||
@@ -65,6 +67,7 @@ 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)
|
||||||
@@ -366,6 +369,18 @@ 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +421,18 @@ 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,6 +55,8 @@ 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 {
|
||||||
@@ -73,6 +75,7 @@ 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(
|
||||||
@@ -156,9 +159,30 @@ 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;
|
||||||
|
|
||||||
@@ -245,13 +269,21 @@ export class PageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.pageRepo.findById(page.id, {
|
const updatedPage = 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(
|
||||||
@@ -487,6 +519,18 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,6 +1059,14 @@ export class PageService {
|
|||||||
pageIds: pageIds,
|
pageIds: pageIds,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const id of pageIds) {
|
||||||
|
this.webhookDispatcher.dispatch(
|
||||||
|
workspaceId,
|
||||||
|
WebhookEvent.PageDeleted,
|
||||||
|
{ id, workspaceId },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ 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 {
|
||||||
@@ -41,6 +43,7 @@ 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(
|
||||||
@@ -85,6 +88,17 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +258,17 @@ 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;
|
||||||
@@ -289,5 +314,15 @@ export class SpaceService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.webhookDispatcher.dispatch(
|
||||||
|
workspaceId,
|
||||||
|
WebhookEvent.SpaceDeleted,
|
||||||
|
{
|
||||||
|
id: spaceId,
|
||||||
|
name: space.name,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ 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 {
|
||||||
@@ -55,6 +57,7 @@ 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) {
|
||||||
@@ -304,6 +307,18 @@ 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,6 +48,8 @@ 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 {
|
||||||
@@ -72,6 +74,7 @@ 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) {
|
||||||
@@ -736,6 +739,17 @@ export class WorkspaceService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.webhookDispatcher.dispatch(
|
||||||
|
workspaceId,
|
||||||
|
WebhookEvent.UserDeactivated,
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateUser(
|
async activateUser(
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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,30 +0,0 @@
|
|||||||
import { Json, Timestamp, Generated } from '@docmost/db/types/db';
|
|
||||||
|
|
||||||
export interface ConfluenceApiImports {
|
|
||||||
id: Generated<string>;
|
|
||||||
fileTaskId: string;
|
|
||||||
siteUrl: string;
|
|
||||||
authType: string;
|
|
||||||
authEmail: string | null;
|
|
||||||
authToken: string | null;
|
|
||||||
authUsername: string | null;
|
|
||||||
totalSpaces: Generated<number>;
|
|
||||||
importedSpaces: Generated<number>;
|
|
||||||
totalPages: Generated<number>;
|
|
||||||
importedPages: Generated<number>;
|
|
||||||
totalUsers: Generated<number>;
|
|
||||||
importedUsers: Generated<number>;
|
|
||||||
totalAttachments: Generated<number>;
|
|
||||||
importedAttachments: Generated<number>;
|
|
||||||
totalLabels: Generated<number>;
|
|
||||||
importedLabels: Generated<number>;
|
|
||||||
idMapping: Generated<Json>;
|
|
||||||
warnings: Generated<Json>;
|
|
||||||
currentPhase: string | null;
|
|
||||||
cancelled: Generated<boolean>;
|
|
||||||
spaceKeys: Generated<Json>;
|
|
||||||
workspaceId: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
+33
@@ -589,6 +589,37 @@ 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;
|
||||||
@@ -625,6 +656,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
import { ConfluenceApiImports } from './custom.types';
|
|
||||||
|
|
||||||
export interface DbInterface extends DB {
|
export interface DbInterface extends DB {
|
||||||
pageEmbeddings: PageEmbeddings;
|
pageEmbeddings: PageEmbeddings;
|
||||||
confluenceApiImports: ConfluenceApiImports;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ 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';
|
||||||
|
|
||||||
@@ -238,3 +240,13 @@ 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: c2d2c373c6...190deffa98
@@ -1,13 +0,0 @@
|
|||||||
export class UnableToInitialize extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(`Unable to initialize the encryption service: ${message}`);
|
|
||||||
this.name = 'UnableToInitialize';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnableToDecrypt extends Error {
|
|
||||||
constructor(reason: string) {
|
|
||||||
super(`Unable to decrypt the ciphertext: ${reason}`);
|
|
||||||
this.name = 'UnableToDecrypt';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { EncryptionService } from './encryption.service';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [EncryptionService],
|
|
||||||
exports: [EncryptionService],
|
|
||||||
})
|
|
||||||
export class EncryptionModule {}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { EncryptionService } from './encryption.service';
|
|
||||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
|
||||||
|
|
||||||
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
|
||||||
|
|
||||||
const buildService = (appSecret: string | undefined) => {
|
|
||||||
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
|
||||||
return new EncryptionService(env);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeEnvelope = (encrypted: string) =>
|
|
||||||
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
|
||||||
iv: string;
|
|
||||||
authTag: string;
|
|
||||||
cipherText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodeEnvelope = (envelope: {
|
|
||||||
iv: string;
|
|
||||||
authTag: string;
|
|
||||||
cipherText: string;
|
|
||||||
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
|
||||||
|
|
||||||
describe('EncryptionService', () => {
|
|
||||||
let service: EncryptionService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
EncryptionService,
|
|
||||||
{
|
|
||||||
provide: EnvironmentService,
|
|
||||||
useValue: { getAppSecret: () => APP_SECRET },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<EncryptionService>(EncryptionService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initialization', () => {
|
|
||||||
it('compiles via Nest DI', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
|
||||||
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
|
||||||
expect(() => buildService('')).toThrow(UnableToInitialize);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('encrypt + decrypt round-trip', () => {
|
|
||||||
it('decrypts back to the original plaintext', () => {
|
|
||||||
const plaintext = 'hello world';
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty string', () => {
|
|
||||||
const encrypted = service.encrypt('');
|
|
||||||
expect(service.decrypt(encrypted)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles unicode (multi-byte UTF-8)', () => {
|
|
||||||
const plaintext = 'héllo 🔐 世界';
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles long plaintext (>1 block)', () => {
|
|
||||||
const plaintext = 'a'.repeat(10_000);
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
|
||||||
const plaintext = 'same input';
|
|
||||||
const a = service.encrypt(plaintext);
|
|
||||||
const b = service.encrypt(plaintext);
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
expect(service.decrypt(a)).toBe(plaintext);
|
|
||||||
expect(service.decrypt(b)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cross-key isolation', () => {
|
|
||||||
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
|
||||||
const other = buildService('totally-different-secret-value-9876543210');
|
|
||||||
const encrypted = service.encrypt('secret');
|
|
||||||
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('tamper detection', () => {
|
|
||||||
it('rejects modified ciphertext', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
|
||||||
tamperedCipher[0] ^= 0x01;
|
|
||||||
const tampered = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
cipherText: tamperedCipher.toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects modified auth tag', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
|
||||||
tamperedTag[0] ^= 0x01;
|
|
||||||
const tampered = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
authTag: tamperedTag.toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects modified IV', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const tamperedIV = Buffer.from(env.iv, 'base64');
|
|
||||||
tamperedIV[0] ^= 0x01;
|
|
||||||
const tampered = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
iv: tamperedIV.toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('malformed payloads', () => {
|
|
||||||
it('rejects non-base64 garbage', () => {
|
|
||||||
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
|
||||||
UnableToDecrypt,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects base64 of non-JSON', () => {
|
|
||||||
const garbage = Buffer.from('not json at all').toString('base64');
|
|
||||||
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects JSON missing required fields', () => {
|
|
||||||
const partial = encodeEnvelope({
|
|
||||||
iv: Buffer.alloc(12).toString('base64'),
|
|
||||||
authTag: Buffer.alloc(16).toString('base64'),
|
|
||||||
} as never);
|
|
||||||
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects wrong-length IV', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const bad = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
iv: Buffer.alloc(8).toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects wrong-length auth tag', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const bad = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
authTag: Buffer.alloc(8).toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('envelope format', () => {
|
|
||||||
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
|
||||||
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
|
||||||
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// https://github.com/nhedger/nestjs-encryption - MIT
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
createCipheriv,
|
|
||||||
createDecipheriv,
|
|
||||||
createHash,
|
|
||||||
randomBytes,
|
|
||||||
} from 'node:crypto';
|
|
||||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
|
||||||
const KEY_DOMAIN = 'docmost:encryption:v1';
|
|
||||||
const IV_LENGTH = 12;
|
|
||||||
const AUTH_TAG_LENGTH = 16;
|
|
||||||
|
|
||||||
type AEADPayload<TFormat = string | Buffer> = {
|
|
||||||
iv: TFormat;
|
|
||||||
authTag: TFormat;
|
|
||||||
cipherText: TFormat;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EncryptionService {
|
|
||||||
private readonly key: Buffer;
|
|
||||||
|
|
||||||
constructor(environmentService: EnvironmentService) {
|
|
||||||
const appSecret = environmentService.getAppSecret();
|
|
||||||
if (!appSecret) {
|
|
||||||
throw new UnableToInitialize('APP_SECRET is not set.');
|
|
||||||
}
|
|
||||||
this.key = createHash('sha256')
|
|
||||||
.update(KEY_DOMAIN)
|
|
||||||
.update(appSecret)
|
|
||||||
.digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
public encrypt(plaintext: string): string {
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
|
||||||
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
|
||||||
const cipherText = Buffer.concat([
|
|
||||||
cipher.update(plaintext, 'utf8'),
|
|
||||||
cipher.final(),
|
|
||||||
]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
const aead: AEADPayload<string> = {
|
|
||||||
iv: iv.toString('base64'),
|
|
||||||
authTag: authTag.toString('base64'),
|
|
||||||
cipherText: cipherText.toString('base64'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
public decrypt(encrypted: string): string {
|
|
||||||
try {
|
|
||||||
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
const decrypted = Buffer.concat([
|
|
||||||
decipher.update(cipherText),
|
|
||||||
decipher.final(),
|
|
||||||
]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
} catch (e: unknown) {
|
|
||||||
throw new UnableToDecrypt((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
|
||||||
const payload = Buffer.from(encodedPayload, 'base64');
|
|
||||||
|
|
||||||
let deserializedPkg: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
deserializedPkg = JSON.parse(payload.toString());
|
|
||||||
} catch {
|
|
||||||
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of ['iv', 'authTag', 'cipherText']) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
|
||||||
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
|
||||||
if (iv.length !== IV_LENGTH) {
|
|
||||||
throw new Error(
|
|
||||||
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
|
||||||
if (authTag.length !== AUTH_TAG_LENGTH) {
|
|
||||||
throw new Error(
|
|
||||||
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cipherText = Buffer.from(
|
|
||||||
deserializedPkg.cipherText as string,
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
|
|
||||||
return { iv, authTag, cipherText };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,9 +28,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
case QueueJob.IMPORT_TASK:
|
case QueueJob.IMPORT_TASK:
|
||||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
case QueueJob.CONFLUENCE_API_IMPORT:
|
|
||||||
await this.processConfluenceApiImport(job.data.fileTaskId);
|
|
||||||
break;
|
|
||||||
case QueueJob.PDF_EXPORT_TASK:
|
case QueueJob.PDF_EXPORT_TASK:
|
||||||
await this.processExportTask(job.data.fileTaskId);
|
await this.processExportTask(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
@@ -52,19 +49,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getConfluenceApiImportService() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const mod = require('./../../../ee/confluence-api-import/confluence-api-import.service');
|
|
||||||
return this.moduleRef.get(mod.ConfluenceApiImportService, {
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processConfluenceApiImport(fileTaskId: string): Promise<void> {
|
|
||||||
const service = this.getConfluenceApiImportService();
|
|
||||||
await service.processImport(fileTaskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||||
const pdfExportService = this.getPdfExportService();
|
const pdfExportService = this.getPdfExportService();
|
||||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||||
@@ -90,8 +74,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.handleFailedImportJob(job);
|
await this.handleFailedImportJob(job);
|
||||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||||
await this.handleFailedExportJob(job);
|
await this.handleFailedExportJob(job);
|
||||||
} else if (job.name === QueueJob.CONFLUENCE_API_IMPORT) {
|
|
||||||
await this.handleFailedExportJob(job);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { nodeIdFromConfluenceAnchor } from './confluence-anchor-id';
|
|
||||||
|
|
||||||
describe('nodeIdFromConfluenceAnchor', () => {
|
|
||||||
it('is deterministic for the same (pageId, anchorName)', () => {
|
|
||||||
const a = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
|
|
||||||
const b = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
|
|
||||||
expect(a).toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns different ids when the anchor name differs', () => {
|
|
||||||
const a = nodeIdFromConfluenceAnchor('page-1', 'one');
|
|
||||||
const b = nodeIdFromConfluenceAnchor('page-1', 'two');
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns different ids when the pageId differs', () => {
|
|
||||||
const a = nodeIdFromConfluenceAnchor('page-1', 'same');
|
|
||||||
const b = nodeIdFromConfluenceAnchor('page-2', 'same');
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns exactly 12 lowercase a-z characters', () => {
|
|
||||||
const id = nodeIdFromConfluenceAnchor('page-xyz', 'Section · 1');
|
|
||||||
expect(id).toHaveLength(12);
|
|
||||||
expect(id).toMatch(/^[a-z]{12}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats an empty anchor name as a valid input', () => {
|
|
||||||
const id = nodeIdFromConfluenceAnchor('page-1', '');
|
|
||||||
expect(id).toMatch(/^[a-z]{12}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
|
|
||||||
// Matches the alphabet used by generateNodeId() in
|
|
||||||
// packages/editor-ext/src/lib/utils.ts (customAlphabet from nanoid).
|
|
||||||
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
|
|
||||||
const NODE_ID_LENGTH = 12;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a deterministic 12-character nodeId for a Confluence anchor.
|
|
||||||
* The same (pageId, anchorName) pair always produces the same result, so
|
|
||||||
* cross-page anchor links resolve to the anchor target without a
|
|
||||||
* precomputed map. The output uses the same alphabet and length as
|
|
||||||
* generateNodeId() from @docmost/editor-ext, so it is interchangeable
|
|
||||||
* with editor-generated nodeIds.
|
|
||||||
*/
|
|
||||||
export function nodeIdFromConfluenceAnchor(
|
|
||||||
pageId: string,
|
|
||||||
anchorName: string,
|
|
||||||
): string {
|
|
||||||
const digest = createHash('sha256')
|
|
||||||
.update(`${pageId}#${anchorName}`)
|
|
||||||
.digest();
|
|
||||||
let out = '';
|
|
||||||
for (let i = 0; i < NODE_ID_LENGTH; i++) {
|
|
||||||
out += ALPHABET[digest[i] % ALPHABET.length];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { parseConfluenceEmojiId } from './confluence-emoji';
|
|
||||||
|
|
||||||
describe('parseConfluenceEmojiId', () => {
|
|
||||||
it('parses a single code point id', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f600')).toBe('😀');
|
|
||||||
expect(parseConfluenceEmojiId('1F600')).toBe('😀');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a country flag (two regional indicator code points)', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f1f3-1f1ec')).toBe('🇳🇬');
|
|
||||||
expect(parseConfluenceEmojiId('1f1fa-1f1f8')).toBe('🇺🇸');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a ZWJ sequence (three code points)', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f468-200d-1f4bb')).toBe('👨💻');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a five-component family ZWJ sequence', () => {
|
|
||||||
// 👨👩👧👦 = man, ZWJ, woman, ZWJ, girl, ZWJ, boy
|
|
||||||
expect(parseConfluenceEmojiId('1f468-200d-1f469-200d-1f467-200d-1f466')).toBe(
|
|
||||||
'👨👩👧👦',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for missing input', () => {
|
|
||||||
expect(parseConfluenceEmojiId(undefined)).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId(null)).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when any segment is not pure hex', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f600-NG')).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('not-hex')).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('1f600--1f1ec')).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('1f600 1f1ec')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when a segment parses to a non-positive value', () => {
|
|
||||||
expect(parseConfluenceEmojiId('0')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for code points outside the valid Unicode range', () => {
|
|
||||||
// 0x110000 is one past the highest valid code point.
|
|
||||||
expect(parseConfluenceEmojiId('110000')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Parse a Confluence emoji id (hex code points joined by hyphens) into a
|
|
||||||
* Unicode string. Confluence emits ids in both single- and multi-code-point
|
|
||||||
* forms:
|
|
||||||
*
|
|
||||||
* "1f600" → "😀"
|
|
||||||
* "1f1f3-1f1ec" → "🇳🇬" (flag: Nigeria)
|
|
||||||
* "1f468-200d-1f4bb" → "👨💻" (man technologist, ZWJ sequence)
|
|
||||||
*
|
|
||||||
* Returns null when the input is missing, empty, or doesn't parse cleanly as
|
|
||||||
* hyphen-separated hex code points.
|
|
||||||
*/
|
|
||||||
export function parseConfluenceEmojiId(
|
|
||||||
raw: string | undefined | null,
|
|
||||||
): string | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
const parts = raw.split('-');
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
if (!parts.every((p) => /^[0-9a-fA-F]+$/.test(p))) return null;
|
|
||||||
const codePoints = parts.map((p) => parseInt(p, 16));
|
|
||||||
if (codePoints.some((cp) => !Number.isFinite(cp) || cp <= 0)) return null;
|
|
||||||
try {
|
|
||||||
return String.fromCodePoint(...codePoints);
|
|
||||||
} catch {
|
|
||||||
// Out-of-range code points throw RangeError on String.fromCodePoint.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { load } from 'cheerio';
|
|
||||||
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
|
||||||
|
|
||||||
function run(html: string): string {
|
|
||||||
const $ = load(html);
|
|
||||||
applyConfluenceMarginLeftIndent($, $.root());
|
|
||||||
// cheerio's html() includes <html><body>; return the body's inner HTML so
|
|
||||||
// tests can assert on the meaningful portion.
|
|
||||||
return $('body').html() ?? $.html();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('applyConfluenceMarginLeftIndent', () => {
|
|
||||||
describe('Confluence Cloud (30 px per level, max 6)', () => {
|
|
||||||
it('maps 30/60/90/120/150/180 px to data-indent 1..6', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 30.0px;">L1</p>' +
|
|
||||||
'<p style="margin-left: 60.0px;">L2</p>' +
|
|
||||||
'<p style="margin-left: 90.0px;">L3</p>' +
|
|
||||||
'<p style="margin-left: 120.0px;">L4</p>' +
|
|
||||||
'<p style="margin-left: 150.0px;">L5</p>' +
|
|
||||||
'<p style="margin-left: 180.0px;">L6</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">L1</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">L2</p>');
|
|
||||||
expect(out).toContain('<p data-indent="3">L3</p>');
|
|
||||||
expect(out).toContain('<p data-indent="4">L4</p>');
|
|
||||||
expect(out).toContain('<p data-indent="5">L5</p>');
|
|
||||||
expect(out).toContain('<p data-indent="6">L6</p>');
|
|
||||||
expect(out).not.toContain('margin-left');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Confluence Data Center (40 px per level, no upper bound)', () => {
|
|
||||||
it('maps 40/80/120/160/200/240 px to data-indent 1..6', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 40.0px;">one</p>' +
|
|
||||||
'<p style="margin-left: 80.0px;">two</p>' +
|
|
||||||
'<p style="margin-left: 120.0px;">three</p>' +
|
|
||||||
'<p style="margin-left: 160.0px;">four</p>' +
|
|
||||||
'<p style="margin-left: 200.0px;">five</p>' +
|
|
||||||
'<p style="margin-left: 240.0px;">six</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">one</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">two</p>');
|
|
||||||
expect(out).toContain('<p data-indent="3">three</p>');
|
|
||||||
expect(out).toContain('<p data-indent="4">four</p>');
|
|
||||||
expect(out).toContain('<p data-indent="5">five</p>');
|
|
||||||
expect(out).toContain('<p data-indent="6">six</p>');
|
|
||||||
expect(out).not.toContain('margin-left');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps DC levels above 8 down to 8', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 320.0px;">L8</p>' +
|
|
||||||
'<p style="margin-left: 360.0px;">L9</p>' +
|
|
||||||
'<p style="margin-left: 600.0px;">L15</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="8">L8</p>');
|
|
||||||
expect(out).toContain('<p data-indent="8">L9</p>');
|
|
||||||
expect(out).toContain('<p data-indent="8">L15</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('headings', () => {
|
|
||||||
it('handles indent on h1-h6 the same way as paragraphs', () => {
|
|
||||||
const html =
|
|
||||||
'<h1 style="margin-left: 30px;">a</h1>' +
|
|
||||||
'<h6 style="margin-left: 90px;">b</h6>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<h1 data-indent="1">a</h1>');
|
|
||||||
expect(out).toContain('<h6 data-indent="3">b</h6>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('style attribute handling', () => {
|
|
||||||
it('strips margin-left but preserves other inline styles', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="color: red; margin-left: 30px; font-weight: bold;">x</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toMatch(/<p style="color: red;\s+font-weight: bold;?" data-indent="1">x<\/p>/);
|
|
||||||
expect(out).not.toContain('margin-left');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the style attribute entirely when only margin-left was set', () => {
|
|
||||||
// Two values so GCD detection sees a real unit (60 px) instead of
|
|
||||||
// collapsing to the lone value. The point of this test is the style
|
|
||||||
// attribute being stripped, not the level number.
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 60px;">x</p>' +
|
|
||||||
'<p style="margin-left: 120px;">y</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">x</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">y</p>');
|
|
||||||
expect(out).not.toContain('style=');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('scope and edge cases', () => {
|
|
||||||
it('leaves elements without margin-left untouched', () => {
|
|
||||||
const html = '<p>plain</p><h2>heading</h2>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toBe('<p>plain</p><h2>heading</h2>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not touch divs, spans, or list items', () => {
|
|
||||||
const html =
|
|
||||||
'<div style="margin-left: 30px;">div</div>' +
|
|
||||||
'<li style="margin-left: 30px;">li</li>' +
|
|
||||||
'<span style="margin-left: 30px;">span</span>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).not.toContain('data-indent');
|
|
||||||
expect(out).toContain('margin-left: 30px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores zero, negative, and unparseable margin-left values', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 0px;">zero</p>' +
|
|
||||||
'<p style="margin-left: -30px;">neg</p>' +
|
|
||||||
'<p style="margin-left: auto;">auto</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).not.toContain('data-indent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors an explicit pxPerLevel override', () => {
|
|
||||||
// Mixed Cloud-and-DC nominal values forced to 40 px/level interpretation.
|
|
||||||
const $ = load(
|
|
||||||
'<p style="margin-left: 40px;">a</p>' +
|
|
||||||
'<p style="margin-left: 80px;">b</p>',
|
|
||||||
);
|
|
||||||
applyConfluenceMarginLeftIndent($, $.root(), { pxPerLevel: 40 });
|
|
||||||
const out = $('body').html() ?? '';
|
|
||||||
expect(out).toContain('<p data-indent="1">a</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">b</p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a no-op when no indented elements are present', () => {
|
|
||||||
const html = '<p>hi</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toBe('<p>hi</p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles a single ambiguous value by clamping to level 1', () => {
|
|
||||||
// GCD of a single value is the value itself, so 120 / 120 = 1.
|
|
||||||
const html = '<p style="margin-left: 120px;">only</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">only</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Cheerio, CheerioAPI } from 'cheerio';
|
|
||||||
|
|
||||||
// Maximum indent level supported by the Indent editor extension (see
|
|
||||||
// packages/editor-ext/src/lib/indent.ts). Values above this clamp down.
|
|
||||||
const MAX_INDENT_LEVEL = 8;
|
|
||||||
const MARGIN_LEFT_RE = /margin-left\s*:\s*(-?\d*\.?\d+)\s*px/i;
|
|
||||||
const MARGIN_LEFT_STRIP_RE = /margin-left\s*:\s*-?\d*\.?\d+\s*px\s*;?/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confluence encodes paragraph indent as inline `style="margin-left: Npx"`.
|
|
||||||
* The per-level pixel value differs by edition: Cloud uses 30 (max 6 levels),
|
|
||||||
* Data Center uses 40 (no upper limit). The HTML-export ZIP path has no
|
|
||||||
* edition information available, so we auto-detect the per-level unit from
|
|
||||||
* the GCD of all margin-left values in the document. The API converter can
|
|
||||||
* pass `pxPerLevel` explicitly when the edition is known.
|
|
||||||
*
|
|
||||||
* Levels are written to `data-indent` for the TipTap Indent extension to
|
|
||||||
* pick up; the margin-left style is stripped from the element so the
|
|
||||||
* normalized indent doesn't double up with the editor's own indent padding.
|
|
||||||
*/
|
|
||||||
export function applyConfluenceMarginLeftIndent(
|
|
||||||
$: CheerioAPI,
|
|
||||||
$root: Cheerio<any>,
|
|
||||||
options?: { pxPerLevel?: number },
|
|
||||||
): void {
|
|
||||||
const $els = $root.find('p, h1, h2, h3, h4, h5, h6');
|
|
||||||
|
|
||||||
const values: number[] = [];
|
|
||||||
$els.each((_, el) => {
|
|
||||||
const style = $(el).attr('style');
|
|
||||||
if (!style) return;
|
|
||||||
const match = MARGIN_LEFT_RE.exec(style);
|
|
||||||
if (!match) return;
|
|
||||||
const px = parseFloat(match[1]);
|
|
||||||
if (Number.isFinite(px) && px > 0) values.push(px);
|
|
||||||
});
|
|
||||||
if (values.length === 0) return;
|
|
||||||
|
|
||||||
const unit = options?.pxPerLevel ?? detectIndentUnit(values);
|
|
||||||
if (!unit || unit <= 0) return;
|
|
||||||
|
|
||||||
$els.each((_, el) => {
|
|
||||||
const $el = $(el);
|
|
||||||
const style = $el.attr('style');
|
|
||||||
if (!style) return;
|
|
||||||
const match = MARGIN_LEFT_RE.exec(style);
|
|
||||||
if (!match) return;
|
|
||||||
const px = parseFloat(match[1]);
|
|
||||||
if (!Number.isFinite(px) || px <= 0) return;
|
|
||||||
const level = Math.min(
|
|
||||||
MAX_INDENT_LEVEL,
|
|
||||||
Math.max(1, Math.round(px / unit)),
|
|
||||||
);
|
|
||||||
$el.attr('data-indent', String(level));
|
|
||||||
const remaining = style.replace(MARGIN_LEFT_STRIP_RE, '').trim();
|
|
||||||
if (remaining) {
|
|
||||||
$el.attr('style', remaining);
|
|
||||||
} else {
|
|
||||||
$el.removeAttr('style');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectIndentUnit(values: number[]): number {
|
|
||||||
// Confluence emits floats like "30.0"; round to ints for a clean GCD.
|
|
||||||
const ints = values.map((v) => Math.round(v)).filter((v) => v > 0);
|
|
||||||
if (ints.length === 0) return 0;
|
|
||||||
return ints.reduce((a, b) => gcd(a, b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function gcd(a: number, b: number): number {
|
|
||||||
while (b !== 0) {
|
|
||||||
[a, b] = [b, a % b];
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export enum FileImportSource {
|
|||||||
Generic = 'generic',
|
Generic = 'generic',
|
||||||
Notion = 'notion',
|
Notion = 'notion',
|
||||||
Confluence = 'confluence',
|
Confluence = 'confluence',
|
||||||
ConfluenceApi = 'confluence-api'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FileTaskStatus {
|
export enum FileTaskStatus {
|
||||||
|
|||||||
@@ -97,21 +97,14 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
|
||||||
export { applyConfluenceMarginLeftIndent };
|
|
||||||
|
|
||||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
normalizeTableColumnWidths($, $root);
|
normalizeTableColumnWidths($, $root);
|
||||||
applyConfluenceMarginLeftIndent($, $root);
|
|
||||||
|
|
||||||
// Auto-embed only when the <a> is the sole meaningful child of its parent
|
|
||||||
// block. A link mixed with surrounding text stays an inline link.
|
|
||||||
$root.find('a[href]').each((_, el) => {
|
$root.find('a[href]').each((_, el) => {
|
||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
const url = $el.attr('href')!;
|
const url = $el.attr('href')!;
|
||||||
const { provider } = getEmbedUrlAndProvider(url);
|
const { provider } = getEmbedUrlAndProvider(url);
|
||||||
if (provider === 'iframe') return;
|
if (provider === 'iframe') return;
|
||||||
if (!isSoleMeaningfulChild($el, el)) return;
|
|
||||||
|
|
||||||
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
||||||
$el.replaceWith(embed);
|
$el.replaceWith(embed);
|
||||||
@@ -127,21 +120,6 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSoleMeaningfulChild(
|
|
||||||
$el: Cheerio<any>,
|
|
||||||
rawEl: any,
|
|
||||||
): boolean {
|
|
||||||
const $parent = $el.parent();
|
|
||||||
if ($parent.length === 0) return true;
|
|
||||||
const others = $parent.contents().toArray().filter((n: any) => {
|
|
||||||
if (n === rawEl) return false;
|
|
||||||
if (n.type === 'text') return (n.data ?? '').trim() !== '';
|
|
||||||
if (n.type === 'tag' && n.name === 'br') return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return others.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_LAYOUTS = [
|
const COLUMN_LAYOUTS = [
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 {
|
||||||
@@ -30,7 +31,6 @@ export enum QueueJob {
|
|||||||
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
||||||
|
|
||||||
IMPORT_TASK = 'import-task',
|
IMPORT_TASK = 'import-task',
|
||||||
CONFLUENCE_API_IMPORT = 'confluence-api-import-task',
|
|
||||||
EXPORT_TASK = 'export-task',
|
EXPORT_TASK = 'export-task',
|
||||||
|
|
||||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
SEARCH_INDEX_PAGE = 'search-index-page',
|
||||||
@@ -84,4 +84,7 @@ 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,6 +92,18 @@ 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],
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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;
|
||||||
@@ -162,28 +162,6 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty callout: delete the whole node so Backspace inside it isn't
|
|
||||||
// a no-op (isolating: true blocks the default join with the block
|
|
||||||
// above).
|
|
||||||
const calloutDepth = $from.depth - 1;
|
|
||||||
if (calloutDepth >= 0) {
|
|
||||||
const calloutNode = $from.node(calloutDepth);
|
|
||||||
if (
|
|
||||||
calloutNode.type === this.type &&
|
|
||||||
calloutNode.childCount === 1 &&
|
|
||||||
calloutNode.firstChild?.content.size === 0
|
|
||||||
) {
|
|
||||||
const calloutPos = $from.before(calloutDepth);
|
|
||||||
const { tr } = state;
|
|
||||||
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
|
|
||||||
tr.setSelection(
|
|
||||||
TextSelection.near(tr.doc.resolve(calloutPos), -1),
|
|
||||||
);
|
|
||||||
view.dispatch(tr);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousPosition = $from.before($from.depth) - 1;
|
const previousPosition = $from.before($from.depth) - 1;
|
||||||
|
|
||||||
// If nothing above to join with
|
// If nothing above to join with
|
||||||
@@ -229,56 +207,6 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Exit the callout into a fresh paragraph below when the cursor sits
|
|
||||||
// in an empty trailing child. An empty callout (single empty
|
|
||||||
// paragraph) exits on the first Enter and keeps the empty callout
|
|
||||||
// intact; a callout with content needs the double-Enter pattern
|
|
||||||
// (first Enter splits, second Enter on the new trailing empty exits
|
|
||||||
// and removes that trailing paragraph).
|
|
||||||
Enter: ({ editor }) => {
|
|
||||||
const { state, view } = editor;
|
|
||||||
const { selection } = state;
|
|
||||||
if (!selection.empty) return false;
|
|
||||||
|
|
||||||
const { $from } = selection;
|
|
||||||
const calloutDepth = $from.depth - 1;
|
|
||||||
if (calloutDepth < 0) return false;
|
|
||||||
|
|
||||||
const calloutNode = $from.node(calloutDepth);
|
|
||||||
if (calloutNode.type !== this.type) return false;
|
|
||||||
if ($from.parent.content.size !== 0) return false;
|
|
||||||
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paragraphType = state.schema.nodes.paragraph;
|
|
||||||
const containerDepth = calloutDepth - 1;
|
|
||||||
const container = $from.node(containerDepth);
|
|
||||||
const indexAfter = $from.indexAfter(containerDepth);
|
|
||||||
if (
|
|
||||||
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const calloutEnd = $from.after(calloutDepth);
|
|
||||||
const paragraph = paragraphType.create();
|
|
||||||
const { tr } = state;
|
|
||||||
|
|
||||||
if (calloutNode.childCount === 1) {
|
|
||||||
tr.insert(calloutEnd, paragraph);
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
|
|
||||||
} else {
|
|
||||||
tr.delete($from.before(), $from.after());
|
|
||||||
const insertPos = tr.mapping.map(calloutEnd);
|
|
||||||
tr.insert(insertPos, paragraph);
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
view.dispatch(tr);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user