Skip to content
File

Blob: src/client/components/webhooks/webhook-card.tsx

typescript190 lines
1import type { WebhookProvider, WebhookSummary } from "@/contracts";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { Plus, Webhook } from "lucide-react";
4import { useCallback, useState } from "react";
5import { Badge, Button, Card, ConfirmDialog, EmptyState, ErrorBanner } from "@/client/components/ui";
6import { formatApiError, getApiClient, queryKeys } from "@/client/lib";
7import { useToast } from "@/client/toast";
8import { AddWebhookDialog } from "@/client/components/webhooks/add-webhook-dialog";
9import { DeliveryListDialog } from "@/client/components/webhooks/delivery-list-dialog";
10import { RotateSecretDialog } from "@/client/components/webhooks/rotate-secret-dialog";
11import { SecretRevealDialog } from "@/client/components/webhooks/secret-reveal-dialog";
12import { WebhookRow } from "@/client/components/webhooks/webhook-row";
13import { getRecommendedProvider } from "@/client/components/webhooks/webhook-presentation";
14import { webhookProviderCatalog } from "@/lib/webhooks";
15 
16interface WebhookCardProps {
17 projectId: string;
18 project: { ownerSlug: string; projectSlug: string; repoUrl: string };
19}
20 
21export const WebhookCard = ({ projectId, project }: WebhookCardProps) => {
22 const { pushToast } = useToast();
23 const queryClient = useQueryClient();
24 
25 const [togglingProvider, setTogglingProvider] = useState<string | null>(null);
26 
27 // Dialog state
28 const [addOpen, setAddOpen] = useState(false);
29 const [secretReveal, setSecretReveal] = useState<{
30 provider: WebhookProvider;
31 secret: string;
32 webhookUrl: string;
33 } | null>(null);
34 const [rotateWebhook, setRotateWebhook] = useState<WebhookSummary | null>(null);
35 const [deliveriesWebhook, setDeliveriesWebhook] = useState<WebhookSummary | null>(null);
36 const [deleteWebhook, setDeleteWebhook] = useState<WebhookSummary | null>(null);
37 
38 const buildWebhookUrl = (provider: string) =>
39 `${window.location.origin}/api/public/hooks/${provider}/${project.ownerSlug}/${project.projectSlug}`;
40 
41 const webhooksQuery = useQuery({
42 queryKey: queryKeys.projectWebhooks(projectId),
43 queryFn: () => getApiClient().getProjectWebhooks(projectId),
44 });
45 
46 const refreshWebhooks = useCallback(async () => {
47 await queryClient.invalidateQueries({ queryKey: queryKeys.projectWebhooks(projectId) });
48 }, [projectId, queryClient]);
49 
50 const handleToggle = async (webhook: WebhookSummary) => {
51 setTogglingProvider(webhook.provider);
52 try {
53 await getApiClient().updateWebhook(projectId, webhook.provider, {
54 enabled: !webhook.enabled,
55 });
56 pushToast({
57 tone: "success",
58 title: webhook.enabled ? "Webhook disabled" : "Webhook enabled",
59 });
60 await refreshWebhooks();
61 } catch (reason) {
62 pushToast({ tone: "error", title: "Toggle failed", message: formatApiError(reason) });
63 } finally {
64 setTogglingProvider(null);
65 }
66 };
67 
68 const handleDelete = async () => {
69 if (!deleteWebhook) return;
70 const provider = deleteWebhook.provider;
71 const displayName = webhookProviderCatalog[provider].displayName;
72 
73 try {
74 await getApiClient().deleteWebhook(projectId, provider);
75 pushToast({ tone: "success", title: `${displayName} webhook deleted` });
76 setDeleteWebhook(null);
77 await refreshWebhooks();
78 } catch (reason) {
79 pushToast({ tone: "error", title: "Delete failed", message: formatApiError(reason) });
80 setDeleteWebhook(null);
81 }
82 };
83 
84 const handleCreated = (provider: WebhookProvider, generatedSecret: string | null, webhookUrl: string) => {
85 void refreshWebhooks();
86 if (generatedSecret) {
87 setSecretReveal({ provider, secret: generatedSecret, webhookUrl });
88 }
89 };
90 
91 const recommended = getRecommendedProvider(project.repoUrl);
92 const webhooks = webhooksQuery.data?.webhooks ?? [];
93 const configuredProviders = webhooks.map((w) => w.provider);
94 const error = webhooksQuery.isError ? formatApiError(webhooksQuery.error) : null;
95 const loading = webhooksQuery.isPending;
96 
97 return (
98 <>
99 <Card>
100 <div className="flex items-center justify-between">
101 <p className="text-xs uppercase tracking-[0.24em] text-zinc-500">Webhooks</p>
102 <Button variant="ghost" size="sm" icon={<Plus className="h-3.5 w-3.5" />} onClick={() => setAddOpen(true)}>
103 Add
104 </Button>
105 </div>
106
107 {loading ? (
108 <p className="mt-3 text-sm text-zinc-500">Loading...</p>
109 ) : error ? (
110 <ErrorBanner message={error} className="mt-3" />
111 ) : webhooks.length === 0 ? (
112 <div className="mt-3">
113 <EmptyState
114 icon={<Webhook className="h-5 w-5" />}
115 title="No webhooks"
116 description="Add a webhook to trigger runs automatically."
117 />
118 {recommended ? (
119 <p className="mt-2 text-center text-xs text-zinc-500">
120 Recommended: <Badge variant="accent">{webhookProviderCatalog[recommended].displayName}</Badge>
121 </p>
122 ) : null}
123 </div>
124 ) : (
125 <div className="mt-3 space-y-2">
126 {webhooks.map((webhook) => (
127 <WebhookRow
128 key={webhook.id}
129 webhook={webhook}
130 toggling={togglingProvider === webhook.provider}
131 onToggle={() => void handleToggle(webhook)}
132 onViewDeliveries={() => setDeliveriesWebhook(webhook)}
133 onRotateSecret={() => setRotateWebhook(webhook)}
134 onDelete={() => setDeleteWebhook(webhook)}
135 />
136 ))}
137 </div>
138 )}
139 </Card>
140
141 <AddWebhookDialog
142 open={addOpen}
143 onClose={() => setAddOpen(false)}
144 projectId={projectId}
145 project={project}
146 configuredProviders={configuredProviders}
147 onCreated={handleCreated}
148 />
149
150 {secretReveal ? (
151 <SecretRevealDialog
152 open
153 onClose={() => setSecretReveal(null)}
154 secret={secretReveal.secret}
155 provider={secretReveal.provider}
156 webhookUrl={secretReveal.webhookUrl}
157 />
158 ) : null}
159
160 {rotateWebhook ? (
161 <RotateSecretDialog
162 open
163 onClose={() => setRotateWebhook(null)}
164 webhook={rotateWebhook}
165 webhookUrl={buildWebhookUrl(rotateWebhook.provider)}
166 onRotated={() => void refreshWebhooks()}
167 />
168 ) : null}
169
170 {deliveriesWebhook ? (
171 <DeliveryListDialog open onClose={() => setDeliveriesWebhook(null)} webhook={deliveriesWebhook} />
172 ) : null}
173
174 <ConfirmDialog
175 open={deleteWebhook !== null}
176 onConfirm={() => void handleDelete()}
177 onCancel={() => setDeleteWebhook(null)}
178 title="Delete webhook"
179 description={
180 deleteWebhook
181 ? `Remove the ${webhookProviderCatalog[deleteWebhook.provider].displayName} webhook? Incoming deliveries from this provider will no longer trigger runs.`
182 : ""
183 }
184 confirmLabel="Delete"
185 variant="danger"
186 />
187 </>
188 );
189};