From 214daa1ec3ab835faafcbfd9445991be83209406 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 15 May 2026 02:07:13 +0100 Subject: [PATCH] feat(webhooks): admin UI for managing webhooks and viewing deliveries --- apps/client/src/App.tsx | 2 + .../components/settings/settings-queries.tsx | 9 + .../components/settings/settings-sidebar.tsx | 12 + .../components/create-webhook-modal.tsx | 140 ++++++++ .../ee/webhook/components/delivery-drawer.tsx | 310 ++++++++++++++++++ .../webhook/components/edit-webhook-modal.tsx | 240 ++++++++++++++ .../components/webhook-secret-modal.tsx | 78 +++++ .../ee/webhook/components/webhook-table.tsx | 226 +++++++++++++ .../ee/webhook/lib/webhook-event-labels.ts | 43 +++ apps/client/src/ee/webhook/pages/webhooks.tsx | 108 ++++++ .../src/ee/webhook/queries/webhook-query.ts | 190 +++++++++++ .../ee/webhook/services/webhook-service.ts | 81 +++++ .../src/ee/webhook/types/webhook.types.ts | 77 +++++ 13 files changed, 1516 insertions(+) create mode 100644 apps/client/src/ee/webhook/components/create-webhook-modal.tsx create mode 100644 apps/client/src/ee/webhook/components/delivery-drawer.tsx create mode 100644 apps/client/src/ee/webhook/components/edit-webhook-modal.tsx create mode 100644 apps/client/src/ee/webhook/components/webhook-secret-modal.tsx create mode 100644 apps/client/src/ee/webhook/components/webhook-table.tsx create mode 100644 apps/client/src/ee/webhook/lib/webhook-event-labels.ts create mode 100644 apps/client/src/ee/webhook/pages/webhooks.tsx create mode 100644 apps/client/src/ee/webhook/queries/webhook-query.ts create mode 100644 apps/client/src/ee/webhook/services/webhook-service.ts create mode 100644 apps/client/src/ee/webhook/types/webhook.types.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 789b48601..e3678485e 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index 892857f55..c75869b20 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -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), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 542cad910..06795a3f1 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -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; diff --git a/apps/client/src/ee/webhook/components/create-webhook-modal.tsx b/apps/client/src/ee/webhook/components/create-webhook-modal.tsx new file mode 100644 index 000000000..df20cfe53 --- /dev/null +++ b/apps/client/src/ee/webhook/components/create-webhook-modal.tsx @@ -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; + +export function CreateWebhookModal({ + opened, + onClose, + onSuccess, +}: CreateWebhookModalProps) { + const { t } = useTranslation(); + const createWebhookMutation = useCreateWebhookMutation(); + + const form = useForm({ + 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 ( + +
+ + + + + + + + + form.setFieldValue("isActive", event.currentTarget.checked) + } + /> + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/webhook/components/delivery-drawer.tsx b/apps/client/src/ee/webhook/components/delivery-drawer.tsx new file mode 100644 index 000000000..53a6a0fdc --- /dev/null +++ b/apps/client/src/ee/webhook/components/delivery-drawer.tsx @@ -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 ( + + + + + {expanded ? ( + + ) : ( + + )} + + {delivery.event} + + + + + + {statusLabel(delivery.status)} + + + + + {delivery.httpStatus ?? "—"} + + + + + {delivery.durationMs != null ? `${delivery.durationMs} ms` : "—"} + + + + + {formattedDate(new Date(delivery.createdAt))} + + + e.stopPropagation()}> + {canRedeliver(delivery.status) ? ( + + ) : null} + + + + + + + + + {t("Payload")} + + + {JSON.stringify(delivery.payload, null, 2)} + + + {delivery.responseBody && ( + <> + + {t("Response body")} + + + {delivery.responseBody} + + + )} + + {delivery.errorMessage && ( + <> + + {t("Error")} + + + {delivery.errorMessage} + + + )} + + + + + + ); +} + +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>(new Set()); + const [pendingId, setPendingId] = useState(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 ( + + + + + + {t("Event")} + {t("Status")} + {t("HTTP")} + {t("Duration")} + {t("Timestamp")} + + + + + {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + )) + ) : data && data.length > 0 ? ( + data.map((delivery) => ( + toggle(delivery.id)} + onRedeliver={() => handleRedeliver(delivery.id)} + isRedelivering={ + pendingId === delivery.id && redeliverMutation.isPending + } + /> + )) + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/client/src/ee/webhook/components/edit-webhook-modal.tsx b/apps/client/src/ee/webhook/components/edit-webhook-modal.tsx new file mode 100644 index 000000000..cd0383669 --- /dev/null +++ b/apps/client/src/ee/webhook/components/edit-webhook-modal.tsx @@ -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; + +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(null); + + const form = useForm({ + 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 ( + <> + +
+ + + + + + + + + form.setFieldValue("isActive", event.currentTarget.checked) + } + /> + + + +
+ + {t("Signing secret")} + + + + + + + {t( + "Rotating generates a new signing secret. The previous secret stops working immediately.", + )} + +
+ + + + + + + + + + + +
+
+
+ + setRevealedSecret(null)} + secret={revealedSecret} + /> + + ); +} diff --git a/apps/client/src/ee/webhook/components/webhook-secret-modal.tsx b/apps/client/src/ee/webhook/components/webhook-secret-modal.tsx new file mode 100644 index 000000000..c34bc3e06 --- /dev/null +++ b/apps/client/src/ee/webhook/components/webhook-secret-modal.tsx @@ -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 ( + + + } + 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.", + )} + + +
+ + {t("Signing secret")} + + + + {secret} + + + +
+ + + {t( + "Use this secret to verify the X-Docmost-Signature header on incoming webhook deliveries.", + )} + + + +
+
+ ); +} diff --git a/apps/client/src/ee/webhook/components/webhook-table.tsx b/apps/client/src/ee/webhook/components/webhook-table.tsx new file mode 100644 index 000000000..d7744639b --- /dev/null +++ b/apps/client/src/ee/webhook/components/webhook-table.tsx @@ -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) => ( + + + + + + + + + + + + + + + + + + + + + ))} + + ); +} + +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: ( + + {t( + "Are you sure you want to delete the webhook {{name}}? This action cannot be undone.", + { name: webhook.name }, + )} + + ), + labels: { confirm: t("Delete"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => { + deleteMutation.mutate({ webhookId: webhook.id }); + }, + }); + }; + + return ( + + + + + {t("Name")} + {t("URL")} + {t("Events")} + {t("Status")} + {t("Created")} + + + + + + {isLoading ? ( + + ) : webhooks && webhooks.length > 0 ? ( + webhooks.map((webhook) => ( + + + onEdit(webhook)} + underline="never" + style={{ color: "var(--mantine-color-text)" }} + > + + {webhook.name} + + + + + + + + {truncate(webhook.url, 60)} + + + + + + + + {t("{{count}} events", { + count: webhook.subscribedEvents.length, + })} + + + + + + + {webhook.isActive ? t("Active") : t("Inactive")} + + + + + + {formattedDate(new Date(webhook.createdAt))} + + + + + + + + + + + + } + onClick={() => onEdit(webhook)} + > + {t("Edit")} + + } + onClick={() => + sendTestMutation.mutate({ webhookId: webhook.id }) + } + > + {t("Send test event")} + + } + onClick={() => onViewDeliveries(webhook)} + > + {t("View deliveries")} + + + } + color="red" + onClick={() => handleDelete(webhook)} + > + {t("Delete")} + + + + + + )) + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/webhook/lib/webhook-event-labels.ts b/apps/client/src/ee/webhook/lib/webhook-event-labels.ts new file mode 100644 index 000000000..3e413169d --- /dev/null +++ b/apps/client/src/ee/webhook/lib/webhook-event-labels.ts @@ -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 })), + })); diff --git a/apps/client/src/ee/webhook/pages/webhooks.tsx b/apps/client/src/ee/webhook/pages/webhooks.tsx new file mode 100644 index 000000000..5f6e562a0 --- /dev/null +++ b/apps/client/src/ee/webhook/pages/webhooks.tsx @@ -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(null); + const [editingWebhookId, setEditingWebhookId] = useState(null); + const [deliveryWebhookId, setDeliveryWebhookId] = useState( + 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 ( + <> + + + {t("Webhooks")} - {getAppName()} + + + + + + + + + + + + + + {data?.items && data.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + setCreateOpened(false)} + onSuccess={(signingSecret) => setRevealedSecret(signingSecret)} + /> + + setRevealedSecret(null)} + secret={revealedSecret} + /> + + setEditingWebhookId(null)} + webhookId={editingWebhookId} + /> + + setDeliveryWebhookId(null)} + webhookId={deliveryWebhookId} + /> + + ); +} diff --git a/apps/client/src/ee/webhook/queries/webhook-query.ts b/apps/client/src/ee/webhook/queries/webhook-query.ts new file mode 100644 index 000000000..1794dcb62 --- /dev/null +++ b/apps/client/src/ee/webhook/queries/webhook-query.ts @@ -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, Error> { + return useQuery({ + queryKey: [WEBHOOK_LIST_KEY, params], + queryFn: () => getWebhooks(params), + placeholderData: keepPreviousData, + }); +} + +export function useWebhook( + webhookId: string | null | undefined, +): UseQueryResult { + return useQuery({ + queryKey: [WEBHOOK_INFO_KEY, webhookId], + queryFn: () => getWebhook(webhookId as string), + enabled: !!webhookId, + }); +} + +export function useWebhookDeliveries( + webhookId: string | null | undefined, +): UseQueryResult { + return useQuery({ + queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId], + queryFn: () => getWebhookDeliveries(webhookId as string), + enabled: !!webhookId, + }); +} + +function invalidateLists(queryClient: ReturnType) { + queryClient.invalidateQueries({ + predicate: (item) => item.queryKey[0] === WEBHOOK_LIST_KEY, + }); +} + +export function useCreateWebhookMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + 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({ + 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" }); + }, + }); +} diff --git a/apps/client/src/ee/webhook/services/webhook-service.ts b/apps/client/src/ee/webhook/services/webhook-service.ts new file mode 100644 index 000000000..8d9d6bbc9 --- /dev/null +++ b/apps/client/src/ee/webhook/services/webhook-service.ts @@ -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> { + const req = await api.post("/webhooks", { ...params }); + return req.data; +} + +export async function getWebhook(webhookId: string): Promise { + const req = await api.post("/webhooks/info", { webhookId }); + return req.data; +} + +export async function createWebhook( + data: ICreateWebhook, +): Promise { + const req = await api.post("/webhooks/create", data); + return req.data; +} + +export async function updateWebhook(data: IUpdateWebhook): Promise { + const req = await api.post("/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 { + const req = await api.post("/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; +} diff --git a/apps/client/src/ee/webhook/types/webhook.types.ts b/apps/client/src/ee/webhook/types/webhook.types.ts new file mode 100644 index 000000000..8aa32b852 --- /dev/null +++ b/apps/client/src/ee/webhook/types/webhook.types.ts @@ -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; + 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; +}