From 1f9f07df20202f8ed2d7f62452e8022618c7e896 Mon Sep 17 00:00:00 2001 From: Nick Meinhold Date: Wed, 11 Mar 2026 09:31:45 +1100 Subject: [PATCH] feat(web): add webhook management UI (#394) * feat(api): add webhook CRUD API router and tests Add tRPC router for managing workspace webhooks: - list, create, update, delete endpoints (admin role required) - test endpoint to send a synthetic payload to a webhook URL - URL validation, event subscription filtering - Unit tests for all router procedures - Integration tests with PGlite test database - Add vitest config and test infrastructure for API package Depends on #391 (DB schema & repository). Co-Authored-By: Claude Opus 4.6 * refactor(api): use assertPermission instead of assertUserInWorkspace Replace assertUserInWorkspace with assertPermission("workspace:manage") per project conventions. The permissions system is the preferred authorization approach for new code. Co-Authored-By: Claude Opus 4.6 * fix(api): use @kan/db alias instead of relative imports in tests Replace relative path imports (../../db/src/...) with the @kan/db alias configured in vitest.config.ts for consistency and robustness. Co-Authored-By: Claude Opus 4.6 * refactor(api): use webhookUrlSchema in router input validation Cherry-pick router-related changes from b2cc9ac: - Use extracted webhookUrlSchema zod validator in create/update input schemas for consistent SSRF checks Co-Authored-By: Claude Opus 4.6 * refactor(api): replace dynamic import with static import for webhook utility Add packages/api/src/utils/webhook.ts with sendWebhookToUrl, createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic import() in the test endpoint with a static import at the top of the file for better tree-shaking, type-checking, and readability. Co-Authored-By: Claude Opus 4.6 * fix(api): align sendWebhooksForWorkspace tests with merged PR #392 The merged delivery utility uses client-side event filtering (getActiveByWorkspaceId takes 2 args, not 3). Update test assertions to match the actual implementation. Co-Authored-By: Claude Opus 4.6 * feat(web): add webhook management UI Add settings page for managing workspace webhooks: - Add webhooks page route and settings navigation link - Add webhook list view with status toggles and action menus - Add create/edit modal with URL validation and event selection - Add delete confirmation dialog - Add WEBHOOKS_ENABLED env flag for feature gating Depends on #393 (CRUD API router). Co-Authored-By: Claude Opus 4.6 * fix(web): remove dead env vars, extract TableRow, import webhookEvents - Remove unused WEBHOOK_URL and WEBHOOK_SECRET env vars (leftovers from earlier env-var-based design) - Move TableRow component outside WebhookList to avoid re-creation on every render - Import webhookEvents from @kan/db/schema instead of hardcoding - Simplify formatDate to only handle Date objects (strings are not returned by tRPC/Superjson) Co-Authored-By: Claude Opus 4.6 * fix(web): gate webhooks settings tab to admin role The webhook API requires admin role, but the settings tab was visible to all users (condition: true). Now matches the API's authorization requirement, addressing reviewer feedback on PR #394. Co-Authored-By: Claude Opus 4.6 * refactor(web): use webhookEvents constant for form defaults Replace hardcoded event arrays with [...webhookEvents] in NewWebhookModal so default values stay in sync if new events are added to the schema. Co-Authored-By: Claude Opus 4.6 * fix(web): use date-fns with locale for webhook date formatting Replace hardcoded toLocaleDateString('en-US') with date-fns format() using the useLocalisation() hook's dateLocale, matching the pattern used throughout the codebase (ActivityList, DateSelector, etc.). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/web/src/components/SettingsLayout.tsx | 7 + apps/web/src/pages/settings/webhooks.tsx | 16 + .../src/views/settings/WebhookSettings.tsx | 78 +++++ .../components/DeleteWebhookConfirmation.tsx | 78 +++++ .../settings/components/NewWebhookModal.tsx | 322 ++++++++++++++++++ .../views/settings/components/WebhookList.tsx | 295 ++++++++++++++++ 6 files changed, 796 insertions(+) create mode 100644 apps/web/src/pages/settings/webhooks.tsx create mode 100644 apps/web/src/views/settings/WebhookSettings.tsx create mode 100644 apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx create mode 100644 apps/web/src/views/settings/components/NewWebhookModal.tsx create mode 100644 apps/web/src/views/settings/components/WebhookList.tsx diff --git a/apps/web/src/components/SettingsLayout.tsx b/apps/web/src/components/SettingsLayout.tsx index 7f6cdcc3..9d312ee1 100644 --- a/apps/web/src/components/SettingsLayout.tsx +++ b/apps/web/src/components/SettingsLayout.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from "react"; import { HiChevronDown, HiOutlineBanknotes, + HiOutlineBolt, HiOutlineCodeBracketSquare, HiOutlineRectangleGroup, HiOutlineShieldCheck, @@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) { label: t`API`, condition: true, }, + { + key: "webhooks", + icon: , + label: t`Webhooks`, + condition: isAdmin, + }, { key: "integrations", icon: , diff --git a/apps/web/src/pages/settings/webhooks.tsx b/apps/web/src/pages/settings/webhooks.tsx new file mode 100644 index 00000000..2719867c --- /dev/null +++ b/apps/web/src/pages/settings/webhooks.tsx @@ -0,0 +1,16 @@ +import type { NextPageWithLayout } from "~/pages/_app"; +import { getDashboardLayout } from "~/components/Dashboard"; +import { SettingsLayout } from "~/components/SettingsLayout"; +import WebhookSettings from "~/views/settings/WebhookSettings"; + +const WebhookSettingsPage: NextPageWithLayout = () => { + return ( + + + + ); +}; + +WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page); + +export default WebhookSettingsPage; diff --git a/apps/web/src/views/settings/WebhookSettings.tsx b/apps/web/src/views/settings/WebhookSettings.tsx new file mode 100644 index 00000000..b6e88588 --- /dev/null +++ b/apps/web/src/views/settings/WebhookSettings.tsx @@ -0,0 +1,78 @@ +import { t } from "@lingui/core/macro"; + +import Button from "~/components/Button"; +import FeedbackModal from "~/components/FeedbackModal"; +import Modal from "~/components/modal"; +import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; +import { PageHead } from "~/components/PageHead"; +import { useModal } from "~/providers/modal"; +import { useWorkspace } from "~/providers/workspace"; +import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation"; +import { NewWebhookModal } from "./components/NewWebhookModal"; +import WebhookList from "./components/WebhookList"; + +export default function WebhookSettings() { + const { modalContentType, openModal, isOpen } = useModal(); + const { workspace } = useWorkspace(); + + if (!workspace) { + return null; + } + + return ( + <> + + +
+

+ {t`Webhooks`} +

+

+ {t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`} +

+ +
+ +
+ + +
+ + {/* Webhook-specific modals */} + + + + + + + + + + + {/* Global modals */} + + + + + + + + ); +} diff --git a/apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx b/apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx new file mode 100644 index 00000000..48a67f9d --- /dev/null +++ b/apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx @@ -0,0 +1,78 @@ +import { t } from "@lingui/core/macro"; +import { HiXMark } from "react-icons/hi2"; + +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface DeleteWebhookConfirmationProps { + workspacePublicId: string; +} + +export function DeleteWebhookConfirmation({ + workspacePublicId, +}: DeleteWebhookConfirmationProps) { + const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); + + const deleteWebhookMutation = api.webhook.delete.useMutation({ + onSuccess: () => { + void utils.webhook.list.invalidate({ workspacePublicId }); + showPopup({ message: t`Webhook deleted successfully`, type: "success" }); + closeModal(); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to delete webhook`, + type: "error", + }); + }, + }); + + const handleDelete = () => { + if (!webhookPublicId) return; + deleteWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhookPublicId as string, + }); + }; + + return ( +
+
+
+

{t`Delete webhook`}

+ +
+ +

+ {t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`} +

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/views/settings/components/NewWebhookModal.tsx b/apps/web/src/views/settings/components/NewWebhookModal.tsx new file mode 100644 index 00000000..6bdbe94d --- /dev/null +++ b/apps/web/src/views/settings/components/NewWebhookModal.tsx @@ -0,0 +1,322 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { t } from "@lingui/core/macro"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { HiXMark } from "react-icons/hi2"; +import { z } from "zod"; + +import { webhookEvents } from "@kan/db/schema"; + +import Button from "~/components/Button"; +import Input from "~/components/Input"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +const newWebhookSchema = z.object({ + name: z + .string() + .min(1, { message: t`Webhook name is required` }) + .max(255, { message: t`Webhook name cannot exceed 255 characters` }), + url: z + .string() + .min(1, { message: t`Webhook URL is required` }) + .url({ message: t`Please enter a valid URL` }) + .max(2048, { message: t`URL cannot exceed 2048 characters` }), + secret: z + .string() + .max(512, { message: t`Secret cannot exceed 512 characters` }) + .optional(), + events: z + .array(z.enum(webhookEvents)) + .min(1, { message: t`Select at least one event` }), + active: z.boolean(), +}); + +type WebhookFormData = z.infer; + +interface NewWebhookModalProps { + workspacePublicId: string; + isEdit?: boolean; +} + +export function NewWebhookModal({ + workspacePublicId, + isEdit = false, +}: NewWebhookModalProps) { + const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal(); + const { showPopup } = usePopup(); + const [isTestingWebhook, setIsTestingWebhook] = useState(false); + + const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null; + + const utils = api.useUtils(); + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(newWebhookSchema), + defaultValues: { + name: "", + url: "", + secret: "", + events: [...webhookEvents], + active: true, + }, + }); + + useEffect(() => { + if (isEdit && webhookPublicId && modalState) { + reset({ + name: modalState.name ?? "", + url: modalState.url ?? "", + secret: "", + events: modalState.events ?? ["card.created"], + active: modalState.active ?? true, + }); + } else if (!isEdit) { + reset({ + name: "", + url: "", + secret: "", + events: [...webhookEvents], + active: true, + }); + } + }, [isEdit, webhookPublicId, modalState, reset]); + + // Clear modal state when closing + useEffect(() => { + return () => { + if (isEdit) { + clearModalState("EDIT_WEBHOOK"); + } + }; + }, [isEdit, clearModalState]); + + const createWebhookMutation = api.webhook.create.useMutation({ + onSuccess: () => { + void utils.webhook.list.invalidate({ workspacePublicId }); + showPopup({ message: t`Webhook created successfully`, type: "success" }); + closeModal(); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to create webhook`, + type: "error", + }); + }, + }); + + const updateWebhookMutation = api.webhook.update.useMutation({ + onSuccess: () => { + void utils.webhook.list.invalidate({ workspacePublicId }); + showPopup({ message: t`Webhook updated successfully`, type: "success" }); + closeModal(); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to update webhook`, + type: "error", + }); + }, + }); + + const testWebhookMutation = api.webhook.test.useMutation({ + onSuccess: (result) => { + if (result.success) { + showPopup({ message: t`Test webhook sent successfully!`, type: "success" }); + } else { + showPopup({ + message: result.error || t`Webhook test failed`, + type: "error", + }); + } + setIsTestingWebhook(false); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to test webhook`, + type: "error", + }); + setIsTestingWebhook(false); + }, + }); + + const onSubmit = (data: WebhookFormData) => { + if (isEdit && webhookPublicId) { + updateWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhookPublicId as string, + name: data.name, + url: data.url, + secret: data.secret || undefined, + events: data.events, + active: data.active, + }); + } else { + createWebhookMutation.mutate({ + workspacePublicId, + name: data.name, + url: data.url, + secret: data.secret || undefined, + events: data.events, + }); + } + }; + + const handleTestWebhook = () => { + if (!webhookPublicId) return; + setIsTestingWebhook(true); + testWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhookPublicId as string, + }); + }; + + const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending; + + return ( +
+
+
+

+ {isEdit ? t`Edit webhook` : t`New webhook`} +

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +

+ {t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`} +

+
+ +
+ + ( +
+ {webhookEvents.map((event) => ( + + ))} +
+ )} + /> + {errors.events && ( +

{errors.events.message}

+ )} +
+ + {isEdit && ( +
+ +
+ )} +
+
+ +
+
+ {isEdit && webhookPublicId && ( + + )} +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/views/settings/components/WebhookList.tsx b/apps/web/src/views/settings/components/WebhookList.tsx new file mode 100644 index 00000000..5915d716 --- /dev/null +++ b/apps/web/src/views/settings/components/WebhookList.tsx @@ -0,0 +1,295 @@ +import { t } from "@lingui/core/macro"; +import type { Locale as DateFnsLocale } from "date-fns"; +import { format } from "date-fns"; +import { HiEllipsisHorizontal } from "react-icons/hi2"; +import { twMerge } from "tailwind-merge"; + +import Dropdown from "~/components/Dropdown"; +import { useLocalisation } from "~/hooks/useLocalisation"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface TableRowProps { + publicId?: string; + name?: string; + url?: string; + events?: string[]; + active?: boolean; + createdAt?: Date | null; + dateLocale?: DateFnsLocale; + isLastRow?: boolean; + showSkeleton?: boolean; + onEdit?: () => void; + onTest?: () => void; + onDelete?: () => void; +} + +function formatEvents(events: string[]) { + return events + .map((e) => e.replace("card.", "")) + .join(", "); +} + +function formatDate(date?: Date | null, locale?: DateFnsLocale) { + if (!date) return "Never"; + return format(date, "MMM d, yyyy", { locale }); +} + +function TableRow({ + publicId, + name, + url, + events, + active, + createdAt, + dateLocale, + isLastRow, + showSkeleton, + onEdit, + onTest, + onDelete, +}: TableRowProps) { + return ( + + +
+
+
+

+ {name} +

+
+
+
+ + +

+ {url} +

+ + +

+ {events && formatEvents(events)} +

+ + + + {!showSkeleton && (active ? t`Active` : t`Inactive`)} + + + +

+ {formatDate(createdAt, dateLocale)} +

+ + + {!showSkeleton && ( +
+
+ onEdit?.(), + }, + { + label: t`Test`, + action: () => onTest?.(), + }, + { + label: t`Delete`, + action: () => onDelete?.(), + }, + ]} + > + + +
+
+ )} + + + ); +} + +interface WebhookListProps { + workspacePublicId: string; +} + +export default function WebhookList({ workspacePublicId }: WebhookListProps) { + const { openModal, setModalState } = useModal(); + const { showPopup } = usePopup(); + const { dateLocale } = useLocalisation(); + + const { data: webhooks, isLoading } = api.webhook.list.useQuery({ + workspacePublicId, + }); + + const testWebhookMutation = api.webhook.test.useMutation({ + onSuccess: (result) => { + if (result.success) { + showPopup({ message: t`Test webhook sent successfully!`, type: "success" }); + } else { + showPopup({ + message: result.error || t`Webhook test failed`, + type: "error", + }); + } + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to test webhook`, + type: "error", + }); + }, + }); + + if (!isLoading && (!webhooks || webhooks.length === 0)) { + return ( +
+

+ {t`No webhooks configured. Add a webhook to receive notifications.`} +

+
+ ); + } + + return ( +
+
+
+
+ + + + + + + + + + + + + {!isLoading && + webhooks?.map((webhook, index) => ( + { + setModalState("EDIT_WEBHOOK", { + publicId: webhook.publicId, + name: webhook.name, + url: webhook.url, + events: webhook.events, + active: webhook.active, + }); + openModal("EDIT_WEBHOOK", webhook.publicId, webhook.name); + }} + onTest={() => { + testWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhook.publicId, + }); + }} + onDelete={() => { + openModal("DELETE_WEBHOOK", webhook.publicId, webhook.name); + }} + /> + ))} + + {isLoading && ( + <> + + + + + )} + +
+ {t`Name`} + + {t`URL`} + + {t`Events`} + + {t`Status`} + + {t`Created`} + + {/* Actions column */} +
+
+
+
+
+ ); +}