feat: add webhooks (#334)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { SettingsNavButton } from "./settings-nav-button";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -11,7 +12,7 @@ export default function ApiKeysPage({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">Developer settings</h1>
|
||||
<H1>Developer Settings</H1>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
|
||||
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
BOUNCE_ERROR_MESSAGES,
|
||||
COMPLAINT_ERROR_MESSAGES,
|
||||
DELIVERY_DELAY_ERRORS,
|
||||
} from "~/lib/constants/ses-errors";
|
||||
} from "@usesend/lib/src/constants/ses-errors";
|
||||
import CancelEmail from "./cancel-email";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
@@ -75,7 +75,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
<span className="text-sm">
|
||||
{formatDate(
|
||||
emailQuery.data?.scheduledAt,
|
||||
"MMM dd'th', hh:mm a"
|
||||
"MMM dd'th', hh:mm a",
|
||||
)}
|
||||
</span>
|
||||
<div className="ml-4">
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@usesend/ui/src/breadcrumb";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import {
|
||||
Edit3,
|
||||
Key,
|
||||
MoreVertical,
|
||||
Pause,
|
||||
Play,
|
||||
TestTube,
|
||||
CircleEllipsis,
|
||||
} from "lucide-react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { WebhookInfo } from "./webhook-info";
|
||||
import { WebhookCallsTable } from "./webhook-calls-table";
|
||||
import { WebhookCallDetails } from "./webhook-call-details";
|
||||
import { DeleteWebhook } from "../delete-webhook";
|
||||
import { EditWebhookDialog } from "../webhook-update-dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@usesend/ui/src/popover";
|
||||
import { type Webhook } from "@prisma/client";
|
||||
|
||||
function WebhookDetailActions({
|
||||
webhook,
|
||||
onTest,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onRotateSecret,
|
||||
isTestPending,
|
||||
isToggling,
|
||||
isRotating,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onToggleStatus: () => void;
|
||||
onRotateSecret: () => void;
|
||||
isTestPending: boolean;
|
||||
isToggling: boolean;
|
||||
isRotating: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPaused = webhook.status === "PAUSED";
|
||||
const isAutoDisabled = webhook.status === "AUTO_DISABLED";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="default" className="gap-1">
|
||||
<MoreVertical className="h-4 -ml-2" />
|
||||
Actions
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 rounded-xl p-1" align="end">
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onTest();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isTestPending}
|
||||
>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
Test webhook
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onToggleStatus();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isToggling || isAutoDisabled}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onRotateSecret();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isRotating}
|
||||
>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Rotate secret
|
||||
</Button>
|
||||
<DeleteWebhook webhook={webhook} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebhookDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ webhookId: string }>;
|
||||
}) {
|
||||
const { webhookId } = use(params);
|
||||
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
const webhookQuery = api.webhook.getById.useQuery({ id: webhookId });
|
||||
const testWebhook = api.webhook.test.useMutation();
|
||||
const setStatusMutation = api.webhook.setStatus.useMutation();
|
||||
const updateWebhook = api.webhook.update.useMutation();
|
||||
const callsQuery = api.webhook.listCalls.useQuery({
|
||||
webhookId,
|
||||
limit: 50,
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const webhook = webhookQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCallId && callsQuery.data?.items.length) {
|
||||
setSelectedCallId(callsQuery.data.items[0]!.id);
|
||||
}
|
||||
}, [callsQuery.data, selectedCallId]);
|
||||
|
||||
const handleTest = () => {
|
||||
testWebhook.mutate(
|
||||
{ id: webhookId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.listCalls.invalidate();
|
||||
toast.success("Test webhook enqueued");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleStatus = (currentStatus: string) => {
|
||||
const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
|
||||
setStatusMutation.mutate(
|
||||
{ id: webhookId, status: newStatus },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.getById.invalidate();
|
||||
toast.success(
|
||||
`Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleRotateSecret = () => {
|
||||
updateWebhook.mutate(
|
||||
{ id: webhookId, rotateSecret: true },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.getById.invalidate();
|
||||
toast.success("Secret rotated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (webhookQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<p className="text-muted-foreground">Loading webhook...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!webhook) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<p className="text-muted-foreground">Webhook not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/webhooks" className="text-lg">
|
||||
Webhooks
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg max-w-[500px] truncate">
|
||||
{webhook.url}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<WebhookDetailActions
|
||||
webhook={webhook}
|
||||
onTest={handleTest}
|
||||
onEdit={() => setIsEditDialogOpen(true)}
|
||||
onToggleStatus={() => handleToggleStatus(webhook.status)}
|
||||
onRotateSecret={handleRotateSecret}
|
||||
isTestPending={testWebhook.isPending}
|
||||
isToggling={setStatusMutation.isPending}
|
||||
isRotating={updateWebhook.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WebhookInfo webhook={webhook} />
|
||||
|
||||
<div className="h-[calc(100vh-280px)] min-h-[600px] flex gap-6">
|
||||
<div className="w-1/2 flex flex-col">
|
||||
<WebhookCallsTable
|
||||
webhookId={webhookId}
|
||||
selectedCallId={selectedCallId}
|
||||
onSelectCall={setSelectedCallId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 overflow-auto">
|
||||
{selectedCallId ? (
|
||||
<WebhookCallDetails callId={selectedCallId} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center border rounded-xl bg-muted/10 border-dashed text-muted-foreground">
|
||||
Select a webhook call to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<EditWebhookDialog
|
||||
webhook={webhook}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "date-fns";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
|
||||
import { WEBHOOK_EVENT_VERSION } from "@usesend/lib/src/webhook/webhook-events";
|
||||
|
||||
import { CodeDisplay } from "~/components/code-display";
|
||||
|
||||
export function WebhookCallDetails({ callId }: { callId: string }) {
|
||||
const callQuery = api.webhook.getCall.useQuery({ id: callId });
|
||||
const retryMutation = api.webhook.retryCall.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const call = callQuery.data;
|
||||
|
||||
if (!call) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<h2 className="text-base font-medium">Call Details</h2>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border shadow p-6 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Loading call details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
retryMutation.mutate(
|
||||
{ id: call.id },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.listCalls.invalidate();
|
||||
await utils.webhook.getCall.invalidate();
|
||||
toast.success("Webhook call queued for retry");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Reconstruct the full payload that was actually sent to the webhook endpoint
|
||||
const buildFullPayload = () => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(call.payload);
|
||||
} catch {
|
||||
data = call.payload;
|
||||
}
|
||||
|
||||
return {
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
version: call.webhook?.apiVersion ?? WEBHOOK_EVENT_VERSION,
|
||||
createdAt: new Date(call.createdAt).toISOString(),
|
||||
teamId: call.teamId,
|
||||
data,
|
||||
attempt: call.attempt,
|
||||
};
|
||||
};
|
||||
|
||||
const fullPayload = buildFullPayload();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<h2 className="text-base font-medium">Call Details</h2>
|
||||
{call.status === "FAILED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
disabled={retryMutation.isPending}
|
||||
className="h-8"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto rounded-xl border shadow p-6 space-y-8 no-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Status
|
||||
</span>
|
||||
<div>
|
||||
<WebhookCallStatusBadge status={call.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Event Type
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.type}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Timestamp
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Attempt
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.attempt}</span>
|
||||
</div>
|
||||
|
||||
{call.responseStatus && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Response Status
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.responseStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{call.responseTimeMs != null && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Duration
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.responseTimeMs}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{call.lastError && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider text-red-500">
|
||||
Error
|
||||
</span>
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/20 rounded-md p-3 font-mono text-red-600 dark:text-red-400">
|
||||
{call.lastError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="font-medium text-sm">Request Payload</h4>
|
||||
<CodeDisplay
|
||||
code={JSON.stringify(fullPayload, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{call.responseText && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="font-medium text-sm">Response Body</h4>
|
||||
<CodeDisplay code={call.responseText} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { WebhookCallStatus } from "@prisma/client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function WebhookCallsTable({
|
||||
webhookId,
|
||||
selectedCallId,
|
||||
onSelectCall,
|
||||
}: {
|
||||
webhookId: string;
|
||||
selectedCallId: string | null;
|
||||
onSelectCall: (callId: string) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState<WebhookCallStatus | "ALL">(
|
||||
"ALL",
|
||||
);
|
||||
const [cursors, setCursors] = useState<string[]>([]);
|
||||
|
||||
const currentCursor = cursors[cursors.length - 1];
|
||||
|
||||
const callsQuery = api.webhook.listCalls.useQuery({
|
||||
webhookId,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: currentCursor,
|
||||
});
|
||||
|
||||
const calls = callsQuery.data?.items ?? [];
|
||||
const nextCursor = callsQuery.data?.nextCursor;
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (nextCursor) {
|
||||
setCursors([...cursors, nextCursor]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCursors(cursors.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleFilterChange = (value: WebhookCallStatus | "ALL") => {
|
||||
setStatusFilter(value);
|
||||
setCursors([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<h2 className="text-base font-medium">Delivery Logs</h2>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(value as WebhookCallStatus | "ALL")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px] h-8 text-xs">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.DELIVERED}>
|
||||
Delivered
|
||||
</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.FAILED}>Failed</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.PENDING}>Pending</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.IN_PROGRESS}>
|
||||
In Progress
|
||||
</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.DISCARDED}>
|
||||
Discarded
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden rounded-xl border shadow flex flex-col">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted dark:bg-muted/70">
|
||||
<TableHead className="h-9 rounded-tl-xl">Status</TableHead>
|
||||
<TableHead className="h-9">Event Type</TableHead>
|
||||
<TableHead className="h-9 rounded-tr-xl">Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="flex-1 overflow-auto no-scrollbar">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{callsQuery.isLoading ? (
|
||||
<TableRow className="h-32 hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : calls.length === 0 ? (
|
||||
<TableRow className="h-32 hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="py-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No webhook calls yet
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
calls.map((call) => (
|
||||
<TableRow
|
||||
key={call.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedCallId === call.id
|
||||
? "bg-accent/50 text-accent-foreground"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => onSelectCall(call.id)}
|
||||
>
|
||||
<TableCell className="py-2">
|
||||
<div className="scale-90 origin-left">
|
||||
<WebhookCallStatusBadge status={call.status} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 font-mono text-xs">
|
||||
{call.type}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(call.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={cursors.length === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleNextPage} disabled={!nextCursor}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Webhook, WebhookCallStatus } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Badge } from "@usesend/ui/src/badge";
|
||||
import { WebhookStatusBadge } from "../webhook-status-badge";
|
||||
|
||||
export function WebhookInfo({ webhook }: { webhook: Webhook }) {
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const callsQuery = api.webhook.listCalls.useQuery({
|
||||
webhookId: webhook.id,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const calls = callsQuery.data?.items ?? [];
|
||||
const last7DaysCalls = calls.filter(
|
||||
(call) => new Date(call.createdAt) >= sevenDaysAgo,
|
||||
);
|
||||
|
||||
const deliveredCount = last7DaysCalls.filter(
|
||||
(c) => c.status === WebhookCallStatus.DELIVERED,
|
||||
).length;
|
||||
const failedCount = last7DaysCalls.filter(
|
||||
(c) => c.status === WebhookCallStatus.FAILED,
|
||||
).length;
|
||||
const pendingCount = last7DaysCalls.filter(
|
||||
(c) =>
|
||||
c.status === WebhookCallStatus.PENDING ||
|
||||
c.status === WebhookCallStatus.IN_PROGRESS,
|
||||
).length;
|
||||
|
||||
const handleCopySecret = () => {
|
||||
navigator.clipboard.writeText(webhook.secret);
|
||||
toast.success("Secret copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-6 justify-between mt-5 mb-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Events</span>
|
||||
<div className="flex items-center gap-1 flex-wrap font-mono text-sm">
|
||||
{webhook.eventTypes.length === 0 ? (
|
||||
<span className="text-sm">All events</span>
|
||||
) : (
|
||||
<>
|
||||
{webhook.eventTypes.slice(0, 2).map((event) => (
|
||||
<Badge key={event} variant="outline">
|
||||
{event}
|
||||
</Badge>
|
||||
))}
|
||||
{webhook.eventTypes.length > 2 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{webhook.eventTypes.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<div className="flex items-center">
|
||||
<WebhookStatusBadge status={webhook.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Created</span>
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Signing Secret</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopySecret}
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono w-[240px] inline-block truncate">
|
||||
{showSecret ? webhook.secret : "whsec_••••••••••••••••••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CodeBlock } from "@usesend/ui/src/code-block";
|
||||
|
||||
interface WebhookPayloadDisplayProps {
|
||||
payload: string;
|
||||
title: string;
|
||||
lang?: "json" | "text";
|
||||
}
|
||||
|
||||
export async function WebhookPayloadDisplay({
|
||||
payload,
|
||||
title,
|
||||
lang = "json",
|
||||
}: WebhookPayloadDisplayProps) {
|
||||
let displayContent = payload;
|
||||
|
||||
// For JSON, try to pretty-print it
|
||||
if (lang === "json") {
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
displayContent = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// If parsing fails, use as-is
|
||||
displayContent = payload;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="font-medium text-sm">{title}</h4>
|
||||
<div className="rounded-lg overflow-hidden border">
|
||||
<CodeBlock lang={lang} className="text-xs max-h-[300px] overflow-auto">
|
||||
{displayContent}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import {
|
||||
ContactEvents,
|
||||
DomainEvents,
|
||||
EmailEvents,
|
||||
WebhookEvents,
|
||||
type WebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@usesend/ui/src/dropdown-menu";
|
||||
import { LimitReason } from "~/lib/constants/plans";
|
||||
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
|
||||
|
||||
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
|
||||
|
||||
const webhookSchema = z.object({
|
||||
url: z
|
||||
.string({ required_error: "URL is required" })
|
||||
.url("Please enter a valid URL"),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM, {
|
||||
required_error: "Select at least one event",
|
||||
}),
|
||||
});
|
||||
|
||||
type WebhookFormValues = z.infer<typeof webhookSchema>;
|
||||
|
||||
const eventGroups: {
|
||||
label: string;
|
||||
events: readonly WebhookEventType[];
|
||||
}[] = [
|
||||
{ label: "Contact events", events: ContactEvents },
|
||||
{ label: "Domain events", events: DomainEvents },
|
||||
{ label: "Email events", events: EmailEvents },
|
||||
];
|
||||
|
||||
export function AddWebhook() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [allEventsSelected, setAllEventsSelected] = useState(false);
|
||||
const createWebhookMutation = api.webhook.create.useMutation();
|
||||
const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK });
|
||||
const { openModal } = useUpgradeModalStore((s) => s.action);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<WebhookFormValues>({
|
||||
resolver: zodResolver(webhookSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
eventTypes: [],
|
||||
},
|
||||
});
|
||||
|
||||
function onOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen && limitsQuery.data?.isLimitReached) {
|
||||
openModal(limitsQuery.data.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
|
||||
function handleSubmit(values: WebhookFormValues) {
|
||||
if (limitsQuery.data?.isLimitReached) {
|
||||
openModal(limitsQuery.data.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedEvents = values.eventTypes ?? [];
|
||||
|
||||
if (!allEventsSelected && selectedEvents.length === 0) {
|
||||
toast.error("Select at least one event or all events");
|
||||
return;
|
||||
}
|
||||
|
||||
createWebhookMutation.mutate(
|
||||
{
|
||||
url: values.url,
|
||||
eventTypes: allEventsSelected ? [] : selectedEvents,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
form.reset({
|
||||
url: "",
|
||||
eventTypes: [],
|
||||
});
|
||||
setAllEventsSelected(false);
|
||||
setOpen(false);
|
||||
toast.success("Webhook created successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
nextOpen !== open ? onOpenChange(nextOpen) : null
|
||||
}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add webhook
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/webhooks/usesend"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field, formState }) => {
|
||||
const selectedEvents = field.value ?? [];
|
||||
const totalEvents = WebhookEvents;
|
||||
|
||||
const selectedCount = allEventsSelected
|
||||
? totalEvents.length
|
||||
: selectedEvents.length;
|
||||
|
||||
const allSelectedLabel =
|
||||
selectedCount === 0
|
||||
? "Select events"
|
||||
: allEventsSelected
|
||||
? "All events"
|
||||
: selectedCount === 1
|
||||
? selectedEvents[0]
|
||||
: `${selectedCount} events selected`;
|
||||
|
||||
const isGroupFullySelected = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) return true;
|
||||
if (selectedEvents.length === 0) return false;
|
||||
return groupEvents.every((event) =>
|
||||
selectedEvents.includes(event),
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setAllEventsSelected(true);
|
||||
field.onChange([]);
|
||||
} else {
|
||||
setAllEventsSelected(false);
|
||||
field.onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGroup = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) {
|
||||
const next = totalEvents.filter(
|
||||
(event) => !groupEvents.includes(event),
|
||||
);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = new Set(selectedEvents);
|
||||
const fullySelected = groupEvents.every((event) =>
|
||||
current.has(event),
|
||||
);
|
||||
|
||||
if (fullySelected) {
|
||||
groupEvents.forEach((event) => current.delete(event));
|
||||
} else {
|
||||
groupEvents.forEach((event) => current.add(event));
|
||||
}
|
||||
|
||||
field.onChange(Array.from(current));
|
||||
};
|
||||
|
||||
const handleToggleEvent = (event: WebhookEventType) => {
|
||||
if (allEventsSelected) {
|
||||
const next = WebhookEvents.filter((e) => e !== event);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = selectedEvents.includes(event);
|
||||
const next = exists
|
||||
? selectedEvents.filter((e) => e !== event)
|
||||
: [...selectedEvents, event];
|
||||
field.onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 inline-flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="truncate text-left text-sm">
|
||||
{allSelectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh] ">
|
||||
<div className="space-y-3">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={allEventsSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleAll(Boolean(checked))
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="font-medium mb-2 px-2"
|
||||
>
|
||||
All events
|
||||
</DropdownMenuCheckboxItem>
|
||||
{eventGroups.map((group) => (
|
||||
<div key={group.label} className="">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isGroupFullySelected(group.events)}
|
||||
onCheckedChange={() =>
|
||||
handleToggleGroup(group.events)
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="px-2 text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
{group.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{group.events.map((event) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={event}
|
||||
checked={
|
||||
allEventsSelected ||
|
||||
selectedEvents.includes(event)
|
||||
}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEvent(event)
|
||||
}
|
||||
onSelect={(event) =>
|
||||
event.preventDefault()
|
||||
}
|
||||
className="pl-3 pr-2 font-mono"
|
||||
>
|
||||
{event}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
{formState.errors.eventTypes ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="w-[120px]"
|
||||
type="submit"
|
||||
disabled={createWebhookMutation.isPending}
|
||||
>
|
||||
{createWebhookMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { DeleteResource } from "~/components/DeleteResource";
|
||||
import { api } from "~/trpc/react";
|
||||
import { type Webhook } from "@prisma/client";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export const DeleteWebhook: React.FC<{
|
||||
webhook: Webhook;
|
||||
}> = ({ webhook }) => {
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
confirmation: z.string().min(1, "Please type the webhook URL to confirm"),
|
||||
})
|
||||
.refine((data) => data.confirmation === webhook.url, {
|
||||
message: "Webhook URL does not match",
|
||||
path: ["confirmation"],
|
||||
});
|
||||
|
||||
async function onConfirm(values: z.infer<typeof schema>) {
|
||||
deleteWebhookMutation.mutate(
|
||||
{ id: webhook.id },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
toast.success("Webhook deleted");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteResource
|
||||
title="Delete webhook"
|
||||
resourceName={webhook.url}
|
||||
schema={schema}
|
||||
isLoading={deleteWebhookMutation.isPending}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel="Delete webhook"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start rounded-lg text-red/80 hover:bg-accent hover:text-red"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { AddWebhook } from "./add-webhook";
|
||||
import { WebhookList } from "./webhook-list";
|
||||
|
||||
export default function WebhooksPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Webhooks</H1>
|
||||
<AddWebhook />
|
||||
</div>
|
||||
<WebhookList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WebhookCallStatus } from "@prisma/client";
|
||||
|
||||
export function WebhookCallStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: WebhookCallStatus;
|
||||
}) {
|
||||
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
|
||||
let label: string = status;
|
||||
|
||||
switch (status) {
|
||||
case WebhookCallStatus.DELIVERED:
|
||||
badgeColor = "bg-green/15 text-green border border-green/20";
|
||||
label = "Delivered";
|
||||
break;
|
||||
case WebhookCallStatus.FAILED:
|
||||
badgeColor = "bg-red/15 text-red border border-red/20";
|
||||
label = "Failed";
|
||||
break;
|
||||
case WebhookCallStatus.PENDING:
|
||||
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
|
||||
label = "Pending";
|
||||
break;
|
||||
case WebhookCallStatus.IN_PROGRESS:
|
||||
badgeColor = "bg-blue/15 text-blue border border-blue/20";
|
||||
label = "In Progress";
|
||||
break;
|
||||
case WebhookCallStatus.DISCARDED:
|
||||
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
|
||||
label = "Discarded";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-center w-[110px] rounded capitalize py-1 text-xs ${badgeColor}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Edit3, MoreVertical, Pause, Play } from "lucide-react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { DeleteWebhook } from "./delete-webhook";
|
||||
import { useState } from "react";
|
||||
import { EditWebhookDialog } from "./webhook-update-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@usesend/ui/src/popover";
|
||||
import { type Webhook } from "@prisma/client";
|
||||
import { WebhookStatusBadge } from "./webhook-status-badge";
|
||||
|
||||
export function WebhookList() {
|
||||
const webhooksQuery = api.webhook.list.useQuery();
|
||||
const testWebhook = api.webhook.test.useMutation();
|
||||
const setStatusMutation = api.webhook.setStatus.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const webhooks = webhooksQuery.data ?? [];
|
||||
|
||||
async function handleToggleStatus(webhookId: string, currentStatus: string) {
|
||||
const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
|
||||
setStatusMutation.mutate(
|
||||
{ id: webhookId, status: newStatus },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
toast.success(
|
||||
`Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="rounded-xl border shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">URL</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last success</TableHead>
|
||||
<TableHead>Last failure</TableHead>
|
||||
<TableHead className="rounded-tr-xl text-right">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooksQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={5} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : webhooks.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={5} className="py-4 text-center">
|
||||
<p>No webhooks configured</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
webhooks.map((webhook) => (
|
||||
<TableRow
|
||||
key={webhook.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/webhooks/${webhook.id}`)}
|
||||
>
|
||||
<TableCell className="max-w-xs truncate ">
|
||||
{webhook.url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebhookStatusBadge status={webhook.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{webhook.lastSuccessAt
|
||||
? formatDistanceToNow(webhook.lastSuccessAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{webhook.lastFailureAt
|
||||
? formatDistanceToNow(webhook.lastFailureAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<WebhookActions
|
||||
webhook={webhook}
|
||||
onEdit={() => setEditingId(webhook.id)}
|
||||
onToggleStatus={() =>
|
||||
handleToggleStatus(webhook.id, webhook.status)
|
||||
}
|
||||
isToggling={setStatusMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{editingId === webhook.id ? (
|
||||
<EditWebhookDialog
|
||||
webhook={webhook}
|
||||
open={editingId === webhook.id}
|
||||
onOpenChange={(open) =>
|
||||
setEditingId(open ? webhook.id : null)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookActions({
|
||||
webhook,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
isToggling,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
onEdit: () => void;
|
||||
onToggleStatus: () => void;
|
||||
isToggling: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPaused = webhook.status === "PAUSED";
|
||||
const isAutoDisabled = webhook.status === "AUTO_DISABLED";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 rounded-xl p-1" align="end">
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onToggleStatus();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isToggling || isAutoDisabled}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DeleteWebhook webhook={webhook} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WebhookStatus } from "@prisma/client";
|
||||
|
||||
export function WebhookStatusBadge({ status }: { status: WebhookStatus }) {
|
||||
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
|
||||
let label: string = status;
|
||||
|
||||
if (status === WebhookStatus.ACTIVE) {
|
||||
badgeColor = "bg-green/15 text-green border border-green/20";
|
||||
label = "Active";
|
||||
} else if (status === WebhookStatus.PAUSED) {
|
||||
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
|
||||
label = "Paused";
|
||||
} else if (status === WebhookStatus.AUTO_DISABLED) {
|
||||
badgeColor = "bg-red/15 text-red border border-red/20";
|
||||
label = "Auto disabled";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
ContactEvents,
|
||||
DomainEvents,
|
||||
EmailEvents,
|
||||
WebhookEvents,
|
||||
type WebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@usesend/ui/src/dropdown-menu";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import type { Webhook } from "@prisma/client";
|
||||
|
||||
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
|
||||
|
||||
const editWebhookSchema = z.object({
|
||||
url: z
|
||||
.string({ required_error: "URL is required" })
|
||||
.url("Please enter a valid URL"),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM, {
|
||||
required_error: "Select at least one event",
|
||||
}),
|
||||
});
|
||||
|
||||
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
|
||||
|
||||
const eventGroups: {
|
||||
label: string;
|
||||
events: readonly WebhookEventType[];
|
||||
}[] = [
|
||||
{ label: "Contact events", events: ContactEvents },
|
||||
{ label: "Domain events", events: DomainEvents },
|
||||
{ label: "Email events", events: EmailEvents },
|
||||
];
|
||||
|
||||
export function EditWebhookDialog({
|
||||
webhook,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const updateWebhook = api.webhook.update.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const initialHasAllEvents =
|
||||
(webhook.eventTypes as WebhookEventType[]).length === 0;
|
||||
const [allEventsSelected, setAllEventsSelected] =
|
||||
useState(initialHasAllEvents);
|
||||
|
||||
const form = useForm<EditWebhookFormValues>({
|
||||
resolver: zodResolver(editWebhookSchema),
|
||||
defaultValues: {
|
||||
url: webhook.url,
|
||||
eventTypes: initialHasAllEvents
|
||||
? []
|
||||
: (webhook.eventTypes as WebhookEventType[]),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const hasAllEvents =
|
||||
(webhook.eventTypes as WebhookEventType[]).length === 0;
|
||||
form.reset({
|
||||
url: webhook.url,
|
||||
eventTypes: hasAllEvents
|
||||
? []
|
||||
: (webhook.eventTypes as WebhookEventType[]),
|
||||
});
|
||||
setAllEventsSelected(hasAllEvents);
|
||||
}
|
||||
}, [open, webhook, form]);
|
||||
|
||||
function handleSubmit(values: EditWebhookFormValues) {
|
||||
const selectedEvents = values.eventTypes ?? [];
|
||||
|
||||
if (!allEventsSelected && selectedEvents.length === 0) {
|
||||
toast.error("Select at least one event or all events");
|
||||
return;
|
||||
}
|
||||
|
||||
updateWebhook.mutate(
|
||||
{
|
||||
id: webhook.id,
|
||||
url: values.url,
|
||||
eventTypes: allEventsSelected ? [] : selectedEvents,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
await utils.webhook.getById.invalidate({ id: webhook.id });
|
||||
toast.success("Webhook updated");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/webhooks/usesend"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field, formState }) => {
|
||||
const selectedEvents = field.value ?? [];
|
||||
const totalEvents = WebhookEvents;
|
||||
|
||||
const selectedCount = allEventsSelected
|
||||
? totalEvents.length
|
||||
: selectedEvents.length;
|
||||
|
||||
const allSelectedLabel =
|
||||
selectedCount === 0
|
||||
? "Select events"
|
||||
: allEventsSelected
|
||||
? "All events"
|
||||
: selectedCount === 1
|
||||
? selectedEvents[0]
|
||||
: `${selectedCount} events selected`;
|
||||
|
||||
const isGroupFullySelected = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) return true;
|
||||
if (selectedEvents.length === 0) return false;
|
||||
return groupEvents.every((event) =>
|
||||
selectedEvents.includes(event),
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setAllEventsSelected(true);
|
||||
field.onChange([]);
|
||||
} else {
|
||||
setAllEventsSelected(false);
|
||||
field.onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGroup = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) {
|
||||
const next = totalEvents.filter(
|
||||
(event) => !groupEvents.includes(event),
|
||||
);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = new Set(selectedEvents);
|
||||
const fullySelected = groupEvents.every((event) =>
|
||||
current.has(event),
|
||||
);
|
||||
|
||||
if (fullySelected) {
|
||||
groupEvents.forEach((event) => current.delete(event));
|
||||
} else {
|
||||
groupEvents.forEach((event) => current.add(event));
|
||||
}
|
||||
|
||||
field.onChange(Array.from(current));
|
||||
};
|
||||
|
||||
const handleToggleEvent = (event: WebhookEventType) => {
|
||||
if (allEventsSelected) {
|
||||
const next = WebhookEvents.filter((e) => e !== event);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = selectedEvents.includes(event);
|
||||
const next = exists
|
||||
? selectedEvents.filter((e) => e !== event)
|
||||
: [...selectedEvents, event];
|
||||
field.onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 inline-flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="truncate text-left text-sm">
|
||||
{allSelectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh]">
|
||||
<div className="space-y-3">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={allEventsSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleAll(Boolean(checked))
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="font-medium mb-2 px-2"
|
||||
>
|
||||
All events
|
||||
</DropdownMenuCheckboxItem>
|
||||
{eventGroups.map((group) => (
|
||||
<div key={group.label} className="">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isGroupFullySelected(group.events)}
|
||||
onCheckedChange={() =>
|
||||
handleToggleGroup(group.events)
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="px-2 text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
{group.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{group.events.map((event) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={event}
|
||||
checked={
|
||||
allEventsSelected ||
|
||||
selectedEvents.includes(event)
|
||||
}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEvent(event)
|
||||
}
|
||||
onSelect={(event) =>
|
||||
event.preventDefault()
|
||||
}
|
||||
className="pl-3 pr-2 font-mono"
|
||||
>
|
||||
{event}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
{formState.errors.eventTypes ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="w-[120px]"
|
||||
type="submit"
|
||||
disabled={updateWebhook.isPending}
|
||||
>
|
||||
{updateWebhook.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user