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 (
+
+ );
+}
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 (
+
+ |
+
+ |
+
+
+ {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 (
+
+
+
+
+
+
+
+ |
+ {t`Name`}
+ |
+
+ {t`URL`}
+ |
+
+ {t`Events`}
+ |
+
+ {t`Status`}
+ |
+
+ {t`Created`}
+ |
+
+ {/* Actions column */}
+ |
+
+
+
+ {!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 && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ );
+}