-
+
+
+
+
-
-
{title}
-
{message}
+
+
- {/* Actions */}
-
+
-
);
-}
\ No newline at end of file
+}
diff --git a/src/features/Webhooks/v1/Webhook.types.ts b/src/features/Webhooks/v1/Webhook.types.ts
new file mode 100644
index 0000000..2abf034
--- /dev/null
+++ b/src/features/Webhooks/v1/Webhook.types.ts
@@ -0,0 +1,75 @@
+export type WebhookEvent =
+ | "member.created"
+ | "member.activated"
+ | "event.created"
+ | "hackathon.created"
+ | "github.push"
+ | "github.pr.opened";
+
+export type WebhookStatus = "active" | "inactive";
+
+export interface Webhook {
+ id: string;
+ name: string;
+ url: string;
+ events: WebhookEvent[];
+ status: WebhookStatus;
+ secret: string; // Typically masked like ********abcd
+ permissions?: string[];
+ lastDeliveryStatus?: "success" | "failed" | "pending";
+ lastTestedAt?: string;
+ lastTestStatus?: "success" | "failed";
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface WebhookLog {
+ id: string;
+ webhookId: string;
+ event: WebhookEvent;
+ status: "success" | "failed";
+ timestamp: string;
+ responseCode: number;
+ requestPayload: unknown;
+ responsePayload: unknown;
+}
+
+export interface CreateWebhookPayload {
+ name: string;
+ url: string;
+ events: WebhookEvent[];
+ secret?: string;
+ permissions?: string[];
+}
+
+export interface UpdateWebhookPayload {
+ name?: string;
+ url?: string;
+ events?: WebhookEvent[];
+ status?: WebhookStatus;
+ secret?: string;
+}
+
+export interface WebhookFilters {
+ status: WebhookStatus | "all";
+ search: string;
+ page: number;
+}
+
+export interface PaginatedWebhooks {
+ data: Webhook[];
+ total: number;
+ totalPages: number;
+}
+
+export interface WebhookLogFilters {
+ status: "all" | "success" | "failed";
+ event: WebhookEvent | "all";
+ page: number;
+}
+
+export interface PaginatedWebhookLogs {
+ data: WebhookLog[];
+ total: number;
+ totalPages: number;
+}
diff --git a/src/features/Webhooks/v1/components/common/MaskedSecret.tsx b/src/features/Webhooks/v1/components/common/MaskedSecret.tsx
new file mode 100644
index 0000000..572b620
--- /dev/null
+++ b/src/features/Webhooks/v1/components/common/MaskedSecret.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import { Eye, EyeOff, Copy, Check } from "lucide-react";
+
+export default function MaskedSecret({ secret }: { secret: string }) {
+ const [show, setShow] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const displaySecret = show ? secret : secret.replace(/./g, "•");
+
+ const copyToClipboard = () => {
+ navigator.clipboard.writeText(secret);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+ {displaySecret}
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/common/StatusBadge.tsx b/src/features/Webhooks/v1/components/common/StatusBadge.tsx
new file mode 100644
index 0000000..b69430b
--- /dev/null
+++ b/src/features/Webhooks/v1/components/common/StatusBadge.tsx
@@ -0,0 +1,18 @@
+import { WebhookStatus } from "../../Webhook.types";
+
+export default function StatusBadge({ status, className = "" }: { status: WebhookStatus; className?: string }) {
+ const isAct = status === "active";
+ return (
+
+
+ {isAct ? "Active" : "Inactive"}
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/form/WebhookForm.test.tsx b/src/features/Webhooks/v1/components/form/WebhookForm.test.tsx
new file mode 100644
index 0000000..baf3e47
--- /dev/null
+++ b/src/features/Webhooks/v1/components/form/WebhookForm.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import WebhookForm from "./WebhookForm";
+import { BrowserRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+// Mock the hooks
+vi.mock("../../hooks/useWebhooks", () => ({
+ useCreateWebhook: () => ({ mutateAsync: vi.fn().mockResolvedValue({}), isPending: false }),
+ useUpdateWebhook: () => ({ mutateAsync: vi.fn().mockResolvedValue({}), isPending: false }),
+}));
+
+vi.mock("@/features/Tasks/v1/components/common/ToastNotification", () => ({
+ useToast: () => ({ addToast: vi.fn() }),
+}));
+
+const queryClient = new QueryClient();
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render(
+
+ {ui}
+
+ );
+};
+
+describe("WebhookForm Component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders correctly in create mode", () => {
+ renderWithProviders(
);
+ expect(screen.getByText("Endpoint Details")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("e.g. Production Slack Alerts")).toBeInTheDocument();
+ });
+
+ it("validates empty form submission", async () => {
+ renderWithProviders(
);
+
+ const submitButton = screen.getByRole("button", { name: /Create Webhook/i });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("Name must be at least 2 characters")).toBeInTheDocument();
+ expect(screen.getByText("Must be a valid URL")).toBeInTheDocument();
+ expect(screen.getByText("Please select at least one event")).toBeInTheDocument();
+ });
+ });
+
+ it("validates HTTPS URL requirement", async () => {
+ renderWithProviders(
);
+
+ const urlInput = screen.getByPlaceholderText("https://your-domain.com/webhooks/commdesk");
+ fireEvent.change(urlInput, { target: { value: "http://insecure-domain.com" } });
+
+ const submitButton = screen.getByRole("button", { name: /Create Webhook/i });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("URL must use HTTPS")).toBeInTheDocument();
+ });
+ });
+
+ it("allows selecting events", async () => {
+ renderWithProviders(
);
+
+ const eventOption = screen.getByText("Member Created");
+ fireEvent.click(eventOption);
+
+ expect(screen.getByText("1 Selected")).toBeInTheDocument();
+ });
+});
diff --git a/src/features/Webhooks/v1/components/form/WebhookForm.tsx b/src/features/Webhooks/v1/components/form/WebhookForm.tsx
new file mode 100644
index 0000000..be8a0eb
--- /dev/null
+++ b/src/features/Webhooks/v1/components/form/WebhookForm.tsx
@@ -0,0 +1,299 @@
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { useNavigate } from "react-router-dom";
+import { Check, Loader2, Link2, Shield, Eye, EyeOff, RefreshCw } from "lucide-react";
+import { WEBHOOK_EVENTS } from "../../constants/webhook.constants";
+import type { WebhookEvent, CreateWebhookPayload, UpdateWebhookPayload } from "../../Webhook.types";
+import { useCreateWebhook, useUpdateWebhook } from "../../hooks/useWebhooks";
+import { useToast } from "@/features/Tasks/v1/components/common/ToastNotification";
+import { Telemetry } from "@/utils/telemetry";
+
+const webhookSchema = z.object({
+ name: z.string().min(2, "Name must be at least 2 characters").max(50, "Name is too long"),
+ url: z.string().url("Must be a valid URL").refine(val => {
+ try {
+ const url = new URL(val);
+ const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
+ if (isLocal) return true;
+ return val.startsWith("https://");
+ } catch {
+ return false;
+ }
+ }, "URL must use HTTPS (except for localhost)"),
+ events: z.array(z.string()).min(1, "Please select at least one event"),
+ secret: z.string().optional(),
+ permissions: z.string().optional(),
+});
+
+
+type FormData = z.infer
;
+
+interface Props {
+ mode: "create" | "edit";
+ initialData?: any;
+}
+
+export default function WebhookForm({ mode, initialData }: Props) {
+ const navigate = useNavigate();
+ const { addToast } = useToast();
+
+ const createWebhook = useCreateWebhook();
+ const updateWebhook = useUpdateWebhook();
+ const isSubmitting = createWebhook.isPending || updateWebhook.isPending;
+
+ const [showSecret, setShowSecret] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(webhookSchema),
+ defaultValues: {
+ name: initialData?.name || "",
+ url: initialData?.url || "",
+ events: initialData?.events || [],
+ secret: "", // Masked initially in edit mode, empty string means unchanged
+ permissions: initialData?.permissions?.join(", ") || "",
+ },
+
+ });
+
+ const selectedEvents = watch("events");
+
+ const toggleEvent = (eventId: string) => {
+ Telemetry.trackAction("webhook_form_toggle_event", { eventId });
+ const current = selectedEvents || [];
+ if (current.includes(eventId)) {
+ setValue("events", current.filter(id => id !== eventId), { shouldValidate: true });
+ } else {
+ setValue("events", [...current, eventId], { shouldValidate: true });
+ }
+ };
+
+ const handleRegenerateSecret = () => {
+ const randomSecret = Array.from(crypto.getRandomValues(new Uint8Array(24)))
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('');
+ setValue("secret", randomSecret, { shouldDirty: true });
+ setShowSecret(true);
+ Telemetry.trackAction("webhook_secret_regenerated");
+ addToast("info", "Secret Generated", "A new secure secret has been generated.");
+ };
+
+ const onSubmit = async (data: FormData) => {
+ try {
+ const parsedPermissions = data.permissions
+ ? data.permissions.split(",").map(p => p.trim()).filter(Boolean)
+ : undefined;
+
+ if (mode === "create") {
+ await createWebhook.mutateAsync({
+ ...data,
+ permissions: parsedPermissions,
+ } as CreateWebhookPayload);
+ Telemetry.trackAction("webhook_created", { eventsCount: data.events.length });
+ addToast("success", "Webhook created", "Your new webhook has been set up successfully.");
+ navigate("/org/dashboard/webhooks");
+ } else {
+ const payload: UpdateWebhookPayload = { name: data.name, url: data.url, events: data.events as WebhookEvent[] };
+ if (data.secret) payload.secret = data.secret; // only update if provided
+ if (parsedPermissions) payload.permissions = parsedPermissions;
+ await updateWebhook.mutateAsync({ id: initialData.id, payload });
+ Telemetry.trackAction("webhook_updated", { id: initialData.id });
+ addToast("success", "Webhook updated", "Changes saved successfully.");
+ navigate("/org/dashboard/webhooks");
+ }
+ } catch (err) {
+ Telemetry.trackError("webhook_save_failed", err);
+ addToast("error", "Error saving webhook", "Please try again later.");
+ }
+ };
+
+ const onError = (errors: any) => {
+ Telemetry.trackFormError("WebhookForm", errors);
+ };
+
+ return (
+
+ );
+}
+
+
diff --git a/src/features/Webhooks/v1/components/layout/BulkActionBar.tsx b/src/features/Webhooks/v1/components/layout/BulkActionBar.tsx
new file mode 100644
index 0000000..fbc6adf
--- /dev/null
+++ b/src/features/Webhooks/v1/components/layout/BulkActionBar.tsx
@@ -0,0 +1,59 @@
+import { Trash2, ShieldCheck, ShieldAlert, X } from "lucide-react";
+
+interface Props {
+ selectedCount: number;
+ onClear: () => void;
+ onAction: (action: "delete" | "enable" | "disable") => void;
+}
+
+export default function BulkActionBar({ selectedCount, onClear, onAction }: Props) {
+ if (selectedCount === 0) return null;
+
+ return (
+
+
+
+
+ {selectedCount} Selected
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/layout/WebhookFilters.tsx b/src/features/Webhooks/v1/components/layout/WebhookFilters.tsx
new file mode 100644
index 0000000..8685744
--- /dev/null
+++ b/src/features/Webhooks/v1/components/layout/WebhookFilters.tsx
@@ -0,0 +1,228 @@
+import { useEffect, useRef, useState, useLayoutEffect } from "react";
+import { createPortal } from "react-dom";
+import { Search, X, SlidersHorizontal, ChevronDown, Check } from "lucide-react";
+import type { WebhookFilters, WebhookStatus } from "../../Webhook.types";
+
+interface Props {
+ filters: WebhookFilters;
+ onChange: (f: WebhookFilters) => void;
+ filteredCount: number;
+ totalCount: number;
+}
+
+const STATUS_DOTS: Record = { all: "bg-gray-400", active: "bg-emerald-500", inactive: "bg-gray-500" };
+
+const STATUS_STYLES: Record = {
+ active: { bg: "var(--cd-success-subtle)", color: "var(--cd-success)", border: "var(--cd-success-subtle)" },
+ inactive: { bg: "var(--cd-surface-3)", color: "var(--cd-text-2)", border: "var(--cd-border)" },
+};
+
+function useDropdown() {
+ const [open, setOpen] = useState(false);
+ const btnRef = useRef(null);
+ const panelRef = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const h = (e: MouseEvent) => {
+ const t = e.target as Node;
+ if (!btnRef.current?.contains(t) && !panelRef.current?.contains(t)) setOpen(false);
+ };
+ document.addEventListener("mousedown", h);
+ return () => document.removeEventListener("mousedown", h);
+ }, [open]);
+ return { open, setOpen, btnRef, panelRef };
+}
+
+function DropdownPortal({ btnRef, panelRef, open, children }: {
+ btnRef: React.RefObject;
+ panelRef: React.RefObject;
+ open: boolean;
+ children: React.ReactNode;
+}) {
+ const [style, setStyle] = useState({});
+ useLayoutEffect(() => {
+ if (!open || !btnRef.current) return;
+ const rect = btnRef.current.getBoundingClientRect();
+ const below = window.innerHeight - rect.bottom;
+ if (below < 240 && rect.top > below)
+ setStyle({ position: "fixed", left: rect.left, bottom: window.innerHeight - rect.top + 6, zIndex: 9999 });
+ else
+ setStyle({ position: "fixed", left: rect.left, top: rect.bottom + 6, zIndex: 9999 });
+ }, [open, btnRef]);
+ if (!open) return null;
+ return createPortal(
+
+ {children}
+
,
+ document.body
+ );
+}
+
+export function PillDropdown({ label: pillLabel, value, options, onChange, dotMap, styleMap }: {
+ label: string;
+ value: T;
+ options: { value: T; label: string }[];
+ onChange: (v: T) => void;
+ dotMap: Record;
+ styleMap: Record;
+}) {
+ const { open, setOpen, btnRef, panelRef } = useDropdown();
+ const isActive = value !== options[0].value;
+ const current = options.find((o) => o.value === value)?.label ?? pillLabel;
+ const activeStyle = styleMap[value] ?? { bg: "var(--cd-primary)", color: "#fff", border: "var(--cd-primary)" };
+
+ return (
+
+
+
+
+ {options.map((opt) => (
+
+ ))}
+
+
+ );
+}
+
+export default function WebhookFiltersBar({ filters, onChange, filteredCount, totalCount }: Props) {
+ const [localSearch, setLocalSearch] = useState(filters.search);
+ const onChangeRef = useRef(onChange);
+
+ useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
+
+ useEffect(() => {
+ if (localSearch === filters.search) return;
+ const timer = setTimeout(() => {
+ onChangeRef.current({ ...filters, search: localSearch });
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [localSearch, filters]);
+
+ const hasActive = filters.status !== "all" || filters.search !== "";
+
+ const resetAll = () => {
+ setLocalSearch("");
+ onChange({ status: "all", search: "" });
+ };
+
+ return (
+
+
+
+
+ {/* Status Dropdown */}
+
+ label="Status"
+ value={filters.status}
+ dotMap={STATUS_DOTS}
+ styleMap={STATUS_STYLES}
+ onChange={(v) => onChange({ ...filters, status: v })}
+ options={[
+ { value: "all", label: "All Status" },
+ { value: "active", label: "Active" },
+ { value: "inactive", label: "Inactive" },
+ ]}
+ />
+
+ {/* Search */}
+
+
+
+ setLocalSearch(e.target.value)}
+ placeholder="Search webhooks by name or URL..."
+ className="flex-1 text-xs bg-transparent outline-none min-w-0"
+ style={{ color: "var(--cd-text)" }}
+ />
+ {localSearch && (
+
+ )}
+
+
+
+ {hasActive && (
+
+ )}
+
+
+
+
+ {hasActive ? (
+
+
+ Showing {filteredCount} results
+
+ ) : (
+ `Total webhooks: ${totalCount}`
+ )}
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/layout/WebhookHeader.tsx b/src/features/Webhooks/v1/components/layout/WebhookHeader.tsx
new file mode 100644
index 0000000..006492c
--- /dev/null
+++ b/src/features/Webhooks/v1/components/layout/WebhookHeader.tsx
@@ -0,0 +1,51 @@
+import { useNavigate } from "react-router-dom";
+import { Plus, Webhook as WebhookIcon } from "lucide-react";
+
+interface Props {
+ totalCount: number;
+ activeCount: number;
+}
+
+export default function WebhookHeader({ totalCount, activeCount }: Props) {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+
+ Webhooks
+
+
+ Manage integrations and real-time events ({activeCount} active of {totalCount})
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/modals/PayloadModal.tsx b/src/features/Webhooks/v1/components/modals/PayloadModal.tsx
new file mode 100644
index 0000000..59f2c6e
--- /dev/null
+++ b/src/features/Webhooks/v1/components/modals/PayloadModal.tsx
@@ -0,0 +1,54 @@
+import { X } from "lucide-react";
+import { useEffect } from "react";
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ payload: any;
+}
+
+export default function PayloadModal({ isOpen, onClose, title, payload }: Props) {
+ // Prevent body scrolling when modal is open
+ useEffect(() => {
+ if (isOpen) document.body.style.overflow = "hidden";
+ else document.body.style.overflow = "";
+ return () => { document.body.style.overflow = ""; };
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
{title}
+
+
+
+
+
+ {JSON.stringify(payload, null, 2)}
+
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/table/LogsCardList.tsx b/src/features/Webhooks/v1/components/table/LogsCardList.tsx
new file mode 100644
index 0000000..27af837
--- /dev/null
+++ b/src/features/Webhooks/v1/components/table/LogsCardList.tsx
@@ -0,0 +1,120 @@
+import { useState } from "react";
+import { CheckCircle2, XCircle, RotateCcw, FileJson, Clock } from "lucide-react";
+import { format } from "date-fns";
+import type { WebhookLog } from "../../Webhook.types";
+import PayloadModal from "../modals/PayloadModal";
+
+interface Props {
+ logs: WebhookLog[];
+ isLoading: boolean;
+ onRetry: (logId: string) => void;
+ isRetrying: boolean;
+}
+
+export default function LogsCardList({ logs, isLoading, onRetry, isRetrying }: Props) {
+ const [selectedPayload, setSelectedPayload] = useState<{ title: string; data: any } | null>(null);
+
+ if (isLoading || logs.length === 0) return null;
+
+ return (
+ <>
+
+ {logs.map((log) => {
+ const isSuccess = log.status === "success";
+ return (
+
+
+
+ {isSuccess ? (
+
+ ) : (
+
+ )}
+
+ {log.status}
+
+
+
= 200 && log.responseCode < 300 ? "var(--cd-success-subtle)" : "var(--cd-danger-subtle)",
+ color: log.responseCode >= 200 && log.responseCode < 300 ? "var(--cd-success)" : "var(--cd-danger)"
+ }}
+ >
+ {log.responseCode}
+
+
+
+
+
+
+
+
+
+
Delivery Time
+
+ {format(new Date(log.timestamp), "MMM d, HH:mm:ss")}
+
+
+
+
+
+
+
+
Trigger Event
+
+ {log.event}
+
+
+
+
+
+
+
+
+ {!isSuccess && (
+
+ )}
+
+
+ );
+ })}
+
+
+ setSelectedPayload(null)}
+ title={selectedPayload?.title || ""}
+ payload={selectedPayload?.data}
+ />
+ >
+ );
+}
diff --git a/src/features/Webhooks/v1/components/table/LogsTable.tsx b/src/features/Webhooks/v1/components/table/LogsTable.tsx
new file mode 100644
index 0000000..8788648
--- /dev/null
+++ b/src/features/Webhooks/v1/components/table/LogsTable.tsx
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import { CheckCircle2, XCircle, RotateCcw, FileJson } from "lucide-react";
+import { format } from "date-fns";
+import type { WebhookLog } from "../../Webhook.types";
+import PayloadModal from "../modals/PayloadModal";
+
+interface Props {
+ logs: WebhookLog[];
+ isLoading: boolean;
+ onRetry: (logId: string) => void;
+ isRetrying: boolean;
+}
+
+export default function LogsTable({ logs, isLoading, onRetry, isRetrying }: Props) {
+ const [selectedPayload, setSelectedPayload] = useState<{ title: string; data: any } | null>(null);
+
+ if (isLoading) {
+ return Loading delivery logs...
;
+ }
+
+ if (logs.length === 0) {
+ return (
+
+
No deliveries found.
+
Deliveries will appear here when an event triggers this webhook.
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ | Status |
+ Timestamp |
+ Event |
+ Code |
+ Payloads |
+
+
+
+ {logs.map((log) => {
+ const isSuccess = log.status === "success";
+ return (
+
+ |
+
+ |
+
+ {format(new Date(log.timestamp), "MMM d, HH:mm:ss")}
+ |
+
+
+ {log.event}
+
+ |
+
+
+ {log.responseCode}
+
+ |
+
+
+
+
+ {!isSuccess && (
+
+ )}
+
+ |
+
+ );
+ })}
+
+
+
+
+ setSelectedPayload(null)}
+ title={selectedPayload?.title || ""}
+ payload={selectedPayload?.data}
+ />
+ >
+ );
+}
diff --git a/src/features/Webhooks/v1/components/table/WebhookCardList.tsx b/src/features/Webhooks/v1/components/table/WebhookCardList.tsx
new file mode 100644
index 0000000..f32442d
--- /dev/null
+++ b/src/features/Webhooks/v1/components/table/WebhookCardList.tsx
@@ -0,0 +1,113 @@
+import { Edit, Trash2, Webhook as WebhookIcon, Power } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import type { Webhook } from "../../Webhook.types";
+import StatusBadge from "../common/StatusBadge";
+
+interface Props {
+ webhooks: Webhook[];
+ isLoading: boolean;
+ onDelete: (w: Webhook) => void;
+ onToggleStatus: (w: Webhook) => void;
+ selectedIds: string[];
+ onToggleSelect: (id: string) => void;
+}
+
+export default function WebhookCardList({
+ webhooks,
+ isLoading,
+ onDelete,
+ onToggleStatus,
+ selectedIds,
+ onToggleSelect
+}: Props) {
+ const navigate = useNavigate();
+
+ if (isLoading) {
+ return (
+
+ Loading webhooks...
+
+ );
+ }
+
+ if (webhooks.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {webhooks.map((w) => (
+
+
+ onToggleSelect(w.id)}
+ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 cursor-pointer accent-indigo-600"
+ />
+
+
+
navigate(`/org/dashboard/webhooks/${w.id}`)}>
+
+
+
+
+
{w.name}
+
+ {w.url.replace(/^https?:\/\//, '')}
+
+
+
+
+
+
+
+
+ {w.events.length} Events
+
+
+
+
+
+ Last:
+
+ {w.lastDeliveryStatus ? w.lastDeliveryStatus.charAt(0).toUpperCase() + w.lastDeliveryStatus.slice(1) : "None"}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/features/Webhooks/v1/components/table/WebhookTable.tsx b/src/features/Webhooks/v1/components/table/WebhookTable.tsx
new file mode 100644
index 0000000..b60e358
--- /dev/null
+++ b/src/features/Webhooks/v1/components/table/WebhookTable.tsx
@@ -0,0 +1,179 @@
+import { MoreHorizontal, Edit, Trash2, Webhook as WebhookIcon, Settings } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import type { Webhook } from "../../Webhook.types";
+import StatusBadge from "../common/StatusBadge";
+import { format } from "date-fns";
+
+interface Props {
+ webhooks: Webhook[];
+ isLoading: boolean;
+ onDelete: (w: Webhook) => void;
+ onToggleStatus: (w: Webhook) => void;
+ selectedIds: string[];
+ onToggleSelect: (id: string) => void;
+ onSelectAll: (ids: string[]) => void;
+}
+
+export default function WebhookTable({
+ webhooks,
+ isLoading,
+ onDelete,
+ onToggleStatus,
+ selectedIds,
+ onToggleSelect,
+ onSelectAll
+}: Props) {
+ const navigate = useNavigate();
+
+ const allSelected = webhooks.length > 0 && selectedIds.length === webhooks.length;
+
+ if (isLoading) {
+ return (
+
+ Loading webhooks...
+
+ );
+ }
+
+ if (webhooks.length === 0) {
+ return null; // Handled by EmptyState in the parent
+ }
+
+ return (
+
+
+
+
+ |
+ onSelectAll(allSelected ? [] : webhooks.map(w => w.id))}
+ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 cursor-pointer accent-indigo-600"
+ />
+ |
+ Name |
+ URL |
+ Status |
+ Last Delivery |
+ Actions |
+
+
+
+ {webhooks.map((w) => {
+ const isSuccess = w.lastDeliveryStatus === "success";
+ const isFailed = w.lastDeliveryStatus === "failed";
+
+ return (
+
+ |
+ onToggleSelect(w.id)}
+ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 cursor-pointer accent-indigo-600"
+ />
+ |
+
+ navigate(`/org/dashboard/webhooks/${w.id}`)}>
+
+
+
+
+ {w.name}
+
+ {w.events.length} event{w.events.length !== 1 ? "s" : ""}
+
+
+
+ |
+
+
+
+
+ {w.url.replace(/^https?:\/\//, '')}
+
+
+ |
+
+
+
+ |
+
+
+
+ {w.lastDeliveryStatus ? (
+ <>
+
+
+ {w.lastDeliveryStatus}
+
+ >
+ ) : (
+ Never
+ )}
+
+ |
+
+
+
+
+
+
+
+ {/* Mobile fallback for ellipsis menu if needed, but opacity-0 works fine for desktop hover */}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/constants/webhook.constants.ts b/src/features/Webhooks/v1/constants/webhook.constants.ts
new file mode 100644
index 0000000..77cfaa9
--- /dev/null
+++ b/src/features/Webhooks/v1/constants/webhook.constants.ts
@@ -0,0 +1,22 @@
+import { WebhookEvent, WebhookFilters, WebhookLogFilters } from "../Webhook.types";
+
+export const WEBHOOK_EVENTS: { id: WebhookEvent; label: string; description: string }[] = [
+ { id: "member.created", label: "Member Created", description: "Triggered when a new member joins." },
+ { id: "member.activated", label: "Member Activated", description: "Triggered when a member account is activated." },
+ { id: "event.created", label: "Event Created", description: "Triggered when a new event is scheduled." },
+ { id: "hackathon.created", label: "Hackathon Created", description: "Triggered when a new hackathon is created." },
+ { id: "github.push", label: "GitHub Push", description: "Triggered on a repository push event." },
+ { id: "github.pr.opened", label: "GitHub PR Opened", description: "Triggered when a pull request is opened." },
+];
+
+export const DEFAULT_WEBHOOK_FILTERS: WebhookFilters = {
+ status: "all",
+ search: "",
+ page: 1,
+};
+
+export const DEFAULT_LOG_FILTERS: WebhookLogFilters = {
+ status: "all",
+ event: "all",
+ page: 1,
+};
diff --git a/src/features/Webhooks/v1/hooks/useWebhookLogs.ts b/src/features/Webhooks/v1/hooks/useWebhookLogs.ts
new file mode 100644
index 0000000..118db50
--- /dev/null
+++ b/src/features/Webhooks/v1/hooks/useWebhookLogs.ts
@@ -0,0 +1,52 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { webhookLogStore } from "../mock/webhookStore";
+import type { WebhookLog, WebhookLogFilters, PaginatedWebhookLogs } from "../Webhook.types";
+
+export function useWebhookLogs(webhookId: string | undefined, filters: WebhookLogFilters) {
+ return useQuery({
+ queryKey: ["webhook-logs", webhookId, filters],
+ queryFn: async () => {
+ await new Promise((r) => setTimeout(r, 600));
+ if (!webhookId) return { data: [], total: 0, totalPages: 0 };
+
+ let logs = webhookLogStore.getByWebhookId(webhookId);
+
+ if (filters.status !== "all") {
+ logs = logs.filter(l => l.status === filters.status);
+ }
+ if (filters.event !== "all") {
+ logs = logs.filter(l => l.event === filters.event);
+ }
+
+ const pageSize = 10;
+ const total = logs.length;
+ const totalPages = Math.ceil(total / pageSize);
+ const page = filters.page || 1;
+ const start = (page - 1) * pageSize;
+ const paginatedLogs = logs.slice(start, start + pageSize);
+
+ return {
+ data: paginatedLogs,
+ total,
+ totalPages,
+ };
+ },
+ enabled: !!webhookId,
+ });
+}
+
+export function useRetryWebhookDelivery() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ webhookId, logId }: { webhookId: string; logId: string }): Promise<{ success: boolean }> => {
+ await new Promise((r) => setTimeout(r, 800));
+ // Mock successful retry
+ return { success: true };
+ },
+ onSuccess: (_, { webhookId }) => {
+ qc.invalidateQueries({ queryKey: ["webhook-logs", webhookId] });
+ qc.invalidateQueries({ queryKey: ["webhooks", webhookId] });
+ qc.invalidateQueries({ queryKey: ["webhooks"] });
+ },
+ });
+}
diff --git a/src/features/Webhooks/v1/hooks/useWebhooks.test.tsx b/src/features/Webhooks/v1/hooks/useWebhooks.test.tsx
new file mode 100644
index 0000000..18db5eb
--- /dev/null
+++ b/src/features/Webhooks/v1/hooks/useWebhooks.test.tsx
@@ -0,0 +1,60 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, it, expect, beforeEach } from "vitest";
+import { useWebhooks, useWebhook, useCreateWebhook } from "./useWebhooks";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { webhookStore } from "../mock/webhookStore";
+
+describe("Webhook API Hooks Integration", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient();
+ // Reset mock store before each test
+ const all = webhookStore.getAll();
+ all.forEach(w => webhookStore.remove(w.id));
+ });
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ it("creates a new webhook successfully", async () => {
+ const { result } = renderHook(() => useCreateWebhook(), { wrapper });
+
+ let newWebhook: any;
+ await result.current.mutateAsync({
+ name: "Test Hook",
+ url: "https://test.com/hook",
+ events: ["member.created"],
+ }).then(res => newWebhook = res);
+
+ expect(newWebhook).toBeDefined();
+ expect(newWebhook.name).toBe("Test Hook");
+ expect(newWebhook.url).toBe("https://test.com/hook");
+ expect(newWebhook.status).toBe("active");
+ });
+
+ it("fetches webhooks with filters", async () => {
+ webhookStore.add({
+ id: "test-1",
+ name: "Alpha Hook",
+ url: "https://alpha.com",
+ events: ["event.created"],
+ status: "active",
+ secret: "sec",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+
+ const { result } = renderHook(() => useWebhooks({ status: "all", search: "Alpha" }), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(result.current.data).toHaveLength(1);
+ expect(result.current.data?.[0].name).toBe("Alpha Hook");
+ });
+});
diff --git a/src/features/Webhooks/v1/hooks/useWebhooks.ts b/src/features/Webhooks/v1/hooks/useWebhooks.ts
new file mode 100644
index 0000000..9d7f8db
--- /dev/null
+++ b/src/features/Webhooks/v1/hooks/useWebhooks.ts
@@ -0,0 +1,160 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { webhookStore } from "../mock/webhookStore";
+import type { Webhook, WebhookFilters, CreateWebhookPayload, UpdateWebhookPayload, PaginatedWebhooks } from "../Webhook.types";
+import { Telemetry } from "@/utils/telemetry";
+
+function applyFilters(webhooks: Webhook[], filters: WebhookFilters): Webhook[] {
+ return webhooks.filter((w) => {
+ if (filters.status !== "all" && w.status !== filters.status) return false;
+ if (filters.search) {
+ const query = filters.search.toLowerCase();
+ const match = w.name.toLowerCase().includes(query) || w.url.toLowerCase().includes(query);
+ if (!match) return false;
+ }
+ return true;
+ });
+}
+
+export function useWebhooks(filters: WebhookFilters) {
+ return useQuery({
+ queryKey: ["webhooks", filters],
+ queryFn: async () => {
+ await new Promise((r) => setTimeout(r, 600)); // Simulate latency
+ const allWebhooks = webhookStore.getAll();
+ const filtered = applyFilters(allWebhooks, filters);
+
+ const pageSize = 10;
+ const total = filtered.length;
+ const totalPages = Math.ceil(total / pageSize);
+ const page = filters.page || 1;
+ const start = (page - 1) * pageSize;
+ const paginatedData = filtered.slice(start, start + pageSize);
+
+ return {
+ data: paginatedData,
+ total,
+ totalPages,
+ };
+ },
+ });
+}
+
+export function useWebhook(id: string | undefined) {
+ return useQuery({
+ queryKey: ["webhooks", id],
+ queryFn: async () => {
+ await new Promise((r) => setTimeout(r, 400));
+ if (!id) return undefined;
+ return webhookStore.getById(id);
+ },
+ enabled: !!id,
+ });
+}
+
+export function useCreateWebhook() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (payload: CreateWebhookPayload): Promise => {
+ await new Promise((r) => setTimeout(r, 500));
+ const newWebhook: Webhook = {
+ id: `wh-${Date.now()}`,
+ name: payload.name,
+ url: payload.url,
+ events: payload.events,
+ status: "active",
+ secret: payload.secret || Array.from(crypto.getRandomValues(new Uint8Array(24))).map(b => b.toString(16).padStart(2, '0')).join(''),
+ permissions: payload.permissions,
+ lastDeliveryStatus: "pending",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ webhookStore.add(newWebhook);
+ return newWebhook;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["webhooks"] }),
+ });
+}
+
+export function useUpdateWebhook() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ id, payload }: { id: string; payload: UpdateWebhookPayload }): Promise => {
+ await new Promise((r) => setTimeout(r, 400));
+ webhookStore.update(id, payload);
+ const updated = webhookStore.getById(id);
+ if (!updated) {
+ Telemetry.trackError("webhook_update_not_found", { id });
+ throw new Error("Webhook not found");
+ }
+ return updated;
+ },
+ onSuccess: (_, { id }) => {
+ qc.invalidateQueries({ queryKey: ["webhooks"] });
+ qc.invalidateQueries({ queryKey: ["webhooks", id] });
+ },
+ });
+}
+
+export function useDeleteWebhook() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: string): Promise => {
+ await new Promise((r) => setTimeout(r, 300));
+ webhookStore.remove(id);
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["webhooks"] }),
+ });
+}
+
+export function useTestWebhook() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: string): Promise<{ success: boolean; message: string }> => {
+ await new Promise((r) => setTimeout(r, 3500)); // Simulated 3.5s delay
+ const webhook = webhookStore.getById(id);
+ if (!webhook) {
+ Telemetry.trackError("webhook_test_not_found", { id });
+ throw new Error("Webhook not found");
+ }
+
+ // 80% chance of success for mock
+ const isSuccess = Math.random() > 0.2;
+
+ webhookStore.update(id, {
+ lastTestedAt: new Date().toISOString(),
+ lastTestStatus: isSuccess ? "success" : "failed"
+ });
+
+ Telemetry.trackAction("webhook_tested", { id, isSuccess });
+ return {
+ success: isSuccess,
+ message: isSuccess ? "Webhook ping successful" : "Failed to reach endpoint"
+ };
+ },
+ onSuccess: (_, id) => {
+ qc.invalidateQueries({ queryKey: ["webhooks"] });
+ qc.invalidateQueries({ queryKey: ["webhooks", id] });
+ },
+ });
+}
+export function useBulkWebhookAction() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ ids, action }: { ids: string[]; action: "delete" | "enable" | "disable" }): Promise => {
+ await new Promise((r) => setTimeout(r, 600)); // Simulated delay
+
+ ids.forEach((id) => {
+ if (action === "delete") {
+ webhookStore.remove(id);
+ } else {
+ webhookStore.update(id, { status: action === "enable" ? "active" : "inactive" });
+ }
+ });
+
+ Telemetry.trackAction("webhooks_bulk_action", { action, count: ids.length });
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["webhooks"] });
+ },
+ });
+}
diff --git a/src/features/Webhooks/v1/mock/webhookStore.ts b/src/features/Webhooks/v1/mock/webhookStore.ts
new file mode 100644
index 0000000..67fe168
--- /dev/null
+++ b/src/features/Webhooks/v1/mock/webhookStore.ts
@@ -0,0 +1,67 @@
+import type { Webhook, WebhookLog } from "../Webhook.types";
+
+let mockWebhooks: Webhook[] = [
+ {
+ id: "wh-1",
+ name: "Slack Notifications",
+ url: "https://hooks.slack.com/services/REPLACE_WITH_YOUR_ACTUAL_WEBHOOK_URL",
+ events: ["member.created", "event.created"],
+ status: "active",
+ secret: "432d56a1b2c3d4e5f6g7h8i9j0k1l2m3",
+ lastDeliveryStatus: "success",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: "wh-2",
+ name: "Zapier Integration",
+ url: "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
+ events: ["hackathon.created"],
+ status: "inactive",
+ secret: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
+ lastDeliveryStatus: "pending",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }
+];
+
+let mockLogs: WebhookLog[] = [
+ {
+ id: "log-1",
+ webhookId: "wh-1",
+ event: "member.created",
+ status: "success",
+ timestamp: new Date().toISOString(),
+ responseCode: 200,
+ requestPayload: { memberId: "m-1", name: "John Doe" },
+ responsePayload: { success: true },
+ },
+ {
+ id: "log-2",
+ webhookId: "wh-1",
+ event: "event.created",
+ status: "failed",
+ timestamp: new Date(Date.now() - 3600000).toISOString(),
+ responseCode: 500,
+ requestPayload: { eventId: "e-1", title: "Tech Meetup" },
+ responsePayload: { error: "Internal Server Error" },
+ }
+];
+
+export const webhookStore = {
+ getAll: () => [...mockWebhooks],
+ getById: (id: string) => mockWebhooks.find((w) => w.id === id),
+ add: (webhook: Webhook) => {
+ mockWebhooks.push(webhook);
+ },
+ update: (id: string, updates: Partial) => {
+ mockWebhooks = mockWebhooks.map((w) => (w.id === id ? { ...w, ...updates, updatedAt: new Date().toISOString() } : w));
+ },
+ remove: (id: string) => {
+ mockWebhooks = mockWebhooks.filter((w) => w.id !== id);
+ },
+};
+
+export const webhookLogStore = {
+ getByWebhookId: (webhookId: string) => mockLogs.filter((l) => l.webhookId === webhookId),
+};
diff --git a/src/features/Webhooks/v1/pages/CreateWebhookPage.tsx b/src/features/Webhooks/v1/pages/CreateWebhookPage.tsx
new file mode 100644
index 0000000..8e4c07c
--- /dev/null
+++ b/src/features/Webhooks/v1/pages/CreateWebhookPage.tsx
@@ -0,0 +1,51 @@
+import { useNavigate } from "react-router-dom";
+import { ArrowLeft, Plus } from "lucide-react";
+import WebhookForm from "../components/form/WebhookForm";
+import { ToastContainer } from "@/features/Tasks/v1/components/common/ToastNotification";
+
+export default function CreateWebhookPage() {
+ const navigate = useNavigate();
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ Create Webhook
+
+
+
+
+ {/* Form content */}
+
+
+
+
+
{}} />
+
+ );
+}
diff --git a/src/features/Webhooks/v1/pages/EditWebhookPage.tsx b/src/features/Webhooks/v1/pages/EditWebhookPage.tsx
new file mode 100644
index 0000000..f2d6a3e
--- /dev/null
+++ b/src/features/Webhooks/v1/pages/EditWebhookPage.tsx
@@ -0,0 +1,63 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { ArrowLeft, Edit } from "lucide-react";
+import WebhookForm from "../components/form/WebhookForm";
+import { useWebhook } from "../hooks/useWebhooks";
+import { ToastContainer } from "@/features/Tasks/v1/components/common/ToastNotification";
+
+export default function EditWebhookPage() {
+ const navigate = useNavigate();
+ const { id } = useParams<{ id: string }>();
+
+ const { data: webhook, isLoading, isError } = useWebhook(id);
+
+ if (isLoading) {
+ return Loading webhook details...
;
+ }
+
+ if (isError || !webhook) {
+ return Webhook not found or failed to load.
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Edit Webhook
+
+
+
+
+ {/* Form content */}
+
+
+
+
+
{}} />
+
+ );
+}
diff --git a/src/features/Webhooks/v1/pages/WebhookDetailsPage.tsx b/src/features/Webhooks/v1/pages/WebhookDetailsPage.tsx
new file mode 100644
index 0000000..2428158
--- /dev/null
+++ b/src/features/Webhooks/v1/pages/WebhookDetailsPage.tsx
@@ -0,0 +1,256 @@
+import { useState } from "react";
+import { useNavigate, useParams, Link } from "react-router-dom";
+import { ArrowLeft, Activity, ShieldAlert, CheckCircle2, Settings2, TestTube2, Loader2, ArrowRight, Signal, Zap, Clock, Terminal, Globe, Code2, AlertCircle, Copy, Check } from "lucide-react";
+import { useWebhook, useTestWebhook } from "../hooks/useWebhooks";
+import { useWebhookLogs } from "../hooks/useWebhookLogs";
+import StatusBadge from "../components/common/StatusBadge";
+import MaskedSecret from "../components/common/MaskedSecret";
+import LogsTable from "../components/table/LogsTable";
+import { ToastContainer, useToast } from "@/features/Tasks/v1/components/common/ToastNotification";
+import { format } from "date-fns";
+
+export default function WebhookDetailsPage() {
+ const navigate = useNavigate();
+ const { id } = useParams<{ id: string }>();
+ const { toasts, addToast, dismiss } = useToast();
+ const [testResult, setTestResult] = useState<{ success: boolean; message: string; timestamp: Date } | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ const { data: webhook, isLoading, isError } = useWebhook(id);
+ const { data: logsData, isLoading: logsLoading } = useWebhookLogs(id, { page: 1, status: "all", event: "all" });
+ const testWebhook = useTestWebhook();
+
+ if (isLoading) return Loading webhook...
;
+ if (isError || !webhook) return Webhook not found.
;
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(webhook.url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ addToast("success", "URL Copied", "Webhook URL added to clipboard");
+ };
+
+ const handleTest = async () => {
+ try {
+ const res = await testWebhook.mutateAsync(webhook.id);
+ setTestResult({ success: res.success, message: res.message, timestamp: new Date() });
+ if (res.success) {
+ addToast("success", "Ping Sent", "Endpoint returned 200 OK");
+ } else {
+ addToast("error", "Test Failed", res.message);
+ }
+ } catch {
+ setTestResult({ success: false, message: "Unable to reach endpoint. Check your URL.", timestamp: new Date() });
+ addToast("error", "Test Failed", "Connection timeout.");
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {webhook.name}
+
+
+
+ {webhook.url}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Unified Dash Stats */}
+
+ {[
+ { label: "Reliability", val: "99.8%", icon: Zap, color: "var(--cd-success)" },
+ { label: "Avg Latency", val: "142ms", icon: Clock, color: "var(--cd-primary)" },
+ { label: "Volume (24h)", val: "1.2k", icon: Activity, color: "var(--cd-text)" },
+ { label: "Status", val: webhook.status, icon: Signal, color: webhook.status === 'active' ? "var(--cd-success)" : "var(--cd-text-muted)" },
+ ].map((stat, i) => (
+
+
{stat.label}
+
+
+ {stat.val}
+
+
+ ))}
+
+
+ {/* Test Result Banner (Conditional) */}
+ {testResult && (
+
+
+
+ {testResult.success ?
:
}
+
+
+
+ {testResult.success ? "Connection Successful" : "Connection Failed"}
+
+
+ {testResult.message} • {format(testResult.timestamp, "HH:mm:ss")}
+
+
+
+
+
+ )}
+
+
+
+ {/* Left Column: Activity & Docs */}
+
+
+ {/* Recent Deliveries */}
+
+
+
+
+
+
+
Recent Deliveries
+
+
Full History
+
+
+ {logsLoading ? (
+
+ Loading Activity...
+
+ ) : (
+
{}} />
+ )}
+
+
{/* Payload Example */}
+
+
+
+
Payload Structure
+
+
+
+
JSON
+
+ {"{"}{"\n"}
+ {" "}"id": "evt_12345",{"\n"}
+ {" "}"type": "{webhook.events[0] || "member.created"}",{"\n"}
+ {" "}"created": {Date.now()},{"\n"}
+ {" "}"data": {"{"}{"\n"}
+ {" "}"object": "member",{"\n"}
+ {" "}"id": "mem_98765",{"\n"}
+ {" "}"status": "active"{"\n"}
+ {" "}{"}"}{"\n"}
+ {"}"}
+
+
+
+ Note: Requests are POSTed with an X-CommDesk-Signature header for verification.
+
+
+
+
+
+
+ {/* Right Column: Configuration */}
+
+
+ {/* Security Sidebar Card */}
+
+
+
+ Security Config
+
+
+
+
+
+
+ Subscriptions
+
+
+ {webhook.events.map(ev => (
+
+ {ev.replace('.', ' ')}
+
+ ))}
+
+
+
+ {webhook.permissions && webhook.permissions.length > 0 && (
+
+
+ Scopes
+
+
+ {webhook.permissions.map(perm => (
+
+ {perm}
+
+ ))}
+
+
+ )}
+
+
+
+
+ Created
+ {format(new Date(webhook.createdAt), "MMM d, yyyy")}
+
+
+ Internal ID
+ {webhook.id}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/pages/WebhookListPage.tsx b/src/features/Webhooks/v1/pages/WebhookListPage.tsx
new file mode 100644
index 0000000..8bda0a5
--- /dev/null
+++ b/src/features/Webhooks/v1/pages/WebhookListPage.tsx
@@ -0,0 +1,273 @@
+import { useState } from "react";
+import { useWebhooks, useUpdateWebhook, useDeleteWebhook, useBulkWebhookAction } from "../hooks/useWebhooks";
+import { DEFAULT_WEBHOOK_FILTERS } from "../constants/webhook.constants";
+import type { Webhook, WebhookFilters } from "../Webhook.types";
+import WebhookHeader from "../components/layout/WebhookHeader";
+import WebhookFiltersBar from "../components/layout/WebhookFilters";
+import WebhookTable from "../components/table/WebhookTable";
+import WebhookCardList from "../components/table/WebhookCardList";
+import BulkActionBar from "../components/layout/BulkActionBar";
+import ConfirmModal from "@/features/Tasks/v1/components/common/ConfirmModal";
+import EmptyState from "@/features/Tasks/v1/components/common/EmptyState";
+import { ToastContainer, useToast } from "@/features/Tasks/v1/components/common/ToastNotification";
+import { useNavigate } from "react-router-dom";
+import { Plus } from "lucide-react";
+
+export default function WebhookListPage() {
+ const navigate = useNavigate();
+ const { toasts, addToast, dismiss } = useToast();
+
+ const [filters, setFilters] = useState(DEFAULT_WEBHOOK_FILTERS);
+ const [selectedIds, setSelectedIds] = useState([]);
+
+ // Total count for header (no filters applied)
+ const { data: allPaginated = { data: [], total: 0, totalPages: 0 } } = useWebhooks(DEFAULT_WEBHOOK_FILTERS);
+ const allWebhooks = allPaginated.data;
+ const totalCount = allPaginated.total;
+
+ // Filtered data
+ const { data: paginatedData, isLoading, isError, refetch } = useWebhooks(filters);
+ const webhooks = paginatedData?.data || [];
+ const totalPages = paginatedData?.totalPages || 0;
+
+ const updateWebhook = useUpdateWebhook();
+ const deleteWebhook = useDeleteWebhook();
+ const bulkAction = useBulkWebhookAction();
+
+ const [webhookToDelete, setWebhookToDelete] = useState(null);
+ const [bulkActionToConfirm, setBulkActionToConfirm] = useState<"delete" | "enable" | "disable" | null>(null);
+
+ const activeCount = allWebhooks.filter(w => w.status === "active").length;
+
+ const handleToggleStatus = async (webhook: Webhook) => {
+ const newStatus = webhook.status === "active" ? "inactive" : "active";
+ try {
+ await updateWebhook.mutateAsync({
+ id: webhook.id,
+ payload: { status: newStatus }
+ });
+ addToast("success", "Status updated", `Webhook is now ${newStatus}`);
+ } catch {
+ addToast("error", "Update failed", "Could not update status");
+ }
+ };
+
+ const handleToggleSelect = (id: string) => {
+ setSelectedIds(prev =>
+ prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
+ );
+ };
+
+ const handleSelectAll = (ids: string[]) => {
+ setSelectedIds(ids);
+ };
+
+ const handleBulkAction = async (actionOverride?: "enable" | "disable") => {
+ const action = actionOverride || bulkActionToConfirm;
+ if (!action) return;
+ try {
+ await bulkAction.mutateAsync({
+ ids: selectedIds,
+ action
+ });
+ addToast(
+ "success",
+ "Bulk Action Successful",
+ `Successfully ${action}d ${selectedIds.length} webhooks.`
+ );
+ setSelectedIds([]);
+ } catch {
+ addToast("error", "Bulk Action Failed", "Something went wrong.");
+ } finally {
+ setBulkActionToConfirm(null);
+ }
+ };
+
+ const handlePageChange = (newPage: number) => {
+ if (newPage >= 1 && newPage <= totalPages) {
+ setFilters(f => ({ ...f, page: newPage }));
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!webhookToDelete) return;
+ try {
+ await deleteWebhook.mutateAsync(webhookToDelete.id);
+ addToast("success", "Webhook deleted", "The webhook has been permanently removed.");
+ } catch {
+ addToast("error", "Delete failed", "Something went wrong.");
+ } finally {
+ setWebhookToDelete(null);
+ }
+ };
+
+ return (
+
+
+
+
+ {isError ? (
+
+ void refetch()} className="cd-btn cd-btn-secondary px-6 py-2.5 rounded-xl border">
+ Retry
+
+ }
+ />
+
+ ) : totalCount === 0 && !isLoading ? (
+
+
navigate("/org/dashboard/webhooks/create")}
+ className="cd-btn cd-btn-primary flex items-center gap-2 px-8 py-3 rounded-2xl shadow-xl shadow-[var(--cd-primary-subtle)] hover:scale-105 transition-all"
+ >
+ Create Webhook
+
+ }
+ />
+
+ ) : (
+
+
setFilters({ ...newFilters, page: 1 })}
+ totalCount={totalCount}
+ filteredCount={paginatedData?.total || 0}
+ />
+
+
+ {/* Desktop View */}
+
+
+
+ {webhooks.length === 0 && !isLoading && (
+
+ setFilters(DEFAULT_WEBHOOK_FILTERS)}
+ className="cd-btn cd-btn-secondary px-6 py-2 rounded-xl border text-sm font-medium"
+ >
+ Clear All Filters
+
+ }
+ />
+
+ )}
+
+
+
+ {/* Mobile View */}
+
+
+
+
+ {/* Pagination UI */}
+ {totalPages > 1 && (
+
+
+ Showing page {filters.page} of {totalPages}
+
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+
void handleDelete()}
+ onCancel={() => setWebhookToDelete(null)}
+ isLoading={deleteWebhook.isPending}
+ danger
+ />
+
+ void handleBulkAction()}
+ onCancel={() => setBulkActionToConfirm(null)}
+ isLoading={bulkAction.isPending}
+ danger
+ />
+
+ setSelectedIds([])}
+ onAction={(action) => {
+ if (action === "delete") {
+ setBulkActionToConfirm("delete");
+ } else {
+ handleBulkAction(action);
+ }
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/features/Webhooks/v1/pages/WebhookLogsPage.tsx b/src/features/Webhooks/v1/pages/WebhookLogsPage.tsx
new file mode 100644
index 0000000..afeb2d9
--- /dev/null
+++ b/src/features/Webhooks/v1/pages/WebhookLogsPage.tsx
@@ -0,0 +1,155 @@
+import { useState } from "react";
+import { useNavigate, useParams, Link } from "react-router-dom";
+import { ArrowLeft, Activity } from "lucide-react";
+import { useWebhook } from "../hooks/useWebhooks";
+import { useWebhookLogs, useRetryWebhookDelivery } from "../hooks/useWebhookLogs";
+import { DEFAULT_LOG_FILTERS } from "../constants/webhook.constants";
+import type { WebhookLogFilters } from "../Webhook.types";
+import LogsTable from "../components/table/LogsTable";
+import LogsCardList from "../components/table/LogsCardList";
+import { ToastContainer, useToast } from "@/features/Tasks/v1/components/common/ToastNotification";
+import { PillDropdown } from "../components/layout/WebhookFilters";
+
+const LOG_STATUS_DOTS: Record = { all: "bg-gray-400", success: "bg-emerald-500", failed: "bg-red-500" };
+const LOG_STATUS_STYLES: Record = {
+ success: { bg: "var(--cd-success-subtle)", color: "var(--cd-success)", border: "var(--cd-success-subtle)" },
+ failed: { bg: "var(--cd-danger-subtle)", color: "var(--cd-danger)", border: "var(--cd-danger-subtle)" },
+};
+
+export default function WebhookLogsPage() {
+ const navigate = useNavigate();
+ const { id } = useParams<{ id: string }>();
+ const { toasts, addToast, dismiss } = useToast();
+
+ const [filters, setFilters] = useState(DEFAULT_LOG_FILTERS);
+
+ const { data: webhook } = useWebhook(id);
+ const { data: paginatedData, isLoading } = useWebhookLogs(id, filters);
+ const logs = paginatedData?.data || [];
+ const totalPages = paginatedData?.totalPages || 0;
+ const retryMutation = useRetryWebhookDelivery();
+
+ const handleRetry = async (logId: string) => {
+ if (!id) return;
+ try {
+ await retryMutation.mutateAsync({ webhookId: id, logId });
+ addToast("success", "Retry triggered", "The delivery has been queued for retry.");
+ } catch {
+ addToast("error", "Retry failed", "Could not trigger the retry mechanism.");
+ }
+ };
+
+ const handlePageChange = (newPage: number) => {
+ if (newPage >= 1 && newPage <= totalPages) {
+ setFilters(f => ({ ...f, page: newPage }));
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ Webhooks
+ /
+ {webhook?.name || 'Loading...'}
+ /
+ Logs
+
+
+
+
+
+
+
+ {/* Filter Bar */}
+
+
+ label="Status"
+ value={filters.status}
+ dotMap={LOG_STATUS_DOTS}
+ styleMap={LOG_STATUS_STYLES}
+ onChange={(v) => setFilters(f => ({ ...f, status: v, page: 1 }))}
+ options={[
+ { value: "all", label: "All Statuses" },
+ { value: "success", label: "Success" },
+ { value: "failed", label: "Failed" },
+ ]}
+ />
+
+ label="Event"
+ value={filters.event}
+ dotMap={{ all: "bg-gray-400" }}
+ styleMap={{}}
+ onChange={(v) => setFilters(f => ({ ...f, event: v, page: 1 }))}
+ options={[
+ { value: "all", label: "All Events" },
+ ...(webhook?.events.map(ev => ({ value: ev, label: ev })) || []),
+ ]}
+ />
+
+
+
+ {/* Desktop View */}
+
+
+ {/* Mobile View */}
+
+
+
+
+ {/* Pagination UI */}
+ {totalPages > 1 && (
+
+
+ Showing page {filters.page} of {totalPages}
+
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/routes/OrgRoute.tsx b/src/routes/OrgRoute.tsx
index e235e19..e39049d 100644
--- a/src/routes/OrgRoute.tsx
+++ b/src/routes/OrgRoute.tsx
@@ -1,3 +1,4 @@
+import { Suspense, lazy } from "react";
import { Route, Routes } from "react-router";
import AddMemberPage from "@/features/AddMember/v1/Page/AddMemberPage";
import Contact from "@/features/Contact_And_Support/v1/Pages/Contact";
@@ -12,39 +13,66 @@ import EditTaskPage from "@/features/Tasks/v1/pages/EditTaskPage";
import TaskDetailPage from "@/features/Tasks/v1/pages/TaskDetailPage";
import TaskManagementPage from "@/features/Tasks/v1/pages/TaskManagementPage";
+import ProtectedRoute from "./ProtectedRoute";
+import { dashboardData } from "@/features/Member/v1/mock/dashboardData";
+
+// Lazy-loaded Webhook pages
+const WebhookListPage = lazy(() => import("@/features/Webhooks/v1/pages/WebhookListPage"));
+const CreateWebhookPage = lazy(() => import("@/features/Webhooks/v1/pages/CreateWebhookPage"));
+const EditWebhookPage = lazy(() => import("@/features/Webhooks/v1/pages/EditWebhookPage"));
+const WebhookDetailsPage = lazy(() => import("@/features/Webhooks/v1/pages/WebhookDetailsPage"));
+const WebhookLogsPage = lazy(() => import("@/features/Webhooks/v1/pages/WebhookLogsPage"));
+
const OrgRoute = () => {
return (
-
+ Loading...
-
+
+
);
};
diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts
new file mode 100644
index 0000000..f95bdd5
--- /dev/null
+++ b/src/utils/telemetry.ts
@@ -0,0 +1,20 @@
+/**
+ * Simple Telemetry/Observability utility for frontend tracking.
+ * In a real-world scenario, this would wrap PostHog, Sentry, Datadog, etc.
+ */
+
+export const Telemetry = {
+ trackAction: (actionName: string, metadata?: Record