feat(webhooks): admin UI for managing webhooks and viewing deliveries

This commit is contained in:
Philipinho
2026-05-15 02:07:13 +01:00
parent 6af74eb3d4
commit 214daa1ec3
13 changed files with 1516 additions and 0 deletions
+2
View File
@@ -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;
}