mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 21:24:09 +08:00
feat(webhooks): admin UI for managing webhooks and viewing deliveries
This commit is contained in:
@@ -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 AiSettings from "@/ee/ai/pages/ai-settings.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 TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
@@ -124,6 +125,7 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"webhooks"} element={<Webhooks />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getApiKeys } from "@/ee/api-key";
|
||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||
import { getWebhooks } from "@/ee/webhook/services/webhook-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
@@ -106,3 +107,11 @@ export const prefetchScimTokens = () => {
|
||||
queryFn: () => getScimTokens({}),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchWebhooks = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["webhook-list", params],
|
||||
queryFn: () => getWebhooks(params),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
IconWebhook,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
prefetchWebhooks,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -125,6 +127,13 @@ const groupedData: DataGroup[] = [
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
{
|
||||
label: "Webhooks",
|
||||
icon: IconWebhook,
|
||||
path: "/settings/webhooks",
|
||||
feature: Feature.WEBHOOKS,
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -222,6 +231,9 @@ export default function SettingsSidebar() {
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Webhooks":
|
||||
prefetchHandler = prefetchWebhooks;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
break;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user