Skip to content
File

Blob: src/client/components/webhooks/add-webhook-dialog.tsx

typescript238 lines
1import { useState } from "react";
2import { Check, Copy, ExternalLink, Webhook } from "lucide-react";
3import type { WebhookProvider } from "@/contracts";
4import { Badge, Button, Dialog, ErrorBanner, Input } from "@/client/components/ui";
5import { formatApiError, getApiClient } from "@/client/lib";
6import { useToast } from "@/client/toast";
7import { inferWebhookInstanceUrl, webhookProviderCatalog, webhookProviderCatalogList } from "@/lib/webhooks";
8import { getRecommendedProvider, getWebhookProviderIcon } from "@/client/components/webhooks/webhook-presentation";
9 
10interface AddWebhookDialogProps {
11 open: boolean;
12 onClose: () => void;
13 projectId: string;
14 project: { ownerSlug: string; projectSlug: string; repoUrl: string };
15 configuredProviders: WebhookProvider[];
16 onCreated: (provider: WebhookProvider, generatedSecret: string | null, webhookUrl: string) => void;
17}
18 
19export const AddWebhookDialog = ({
20 open,
21 onClose,
22 projectId,
23 project,
24 configuredProviders,
25 onCreated,
26}: AddWebhookDialogProps) => {
27 const { pushToast } = useToast();
28 
29 const [step, setStep] = useState<1 | 2>(1);
30 const [selectedProvider, setSelectedProvider] = useState<WebhookProvider | null>(null);
31 const [instanceUrl, setInstanceUrl] = useState("");
32 const [creating, setCreating] = useState(false);
33 const [error, setError] = useState<string | null>(null);
34 const [copiedUrl, setCopiedUrl] = useState(false);
35 
36 const handleClose = () => {
37 setStep(1);
38 setSelectedProvider(null);
39 setInstanceUrl("");
40 setCreating(false);
41 setError(null);
42 setCopiedUrl(false);
43 onClose();
44 };
45 
46 const recommended = getRecommendedProvider(project.repoUrl);
47 const catalog = selectedProvider ? webhookProviderCatalog[selectedProvider] : null;
48 
49 const webhookUrl = selectedProvider
50 ? `${window.location.origin}/api/public/hooks/${selectedProvider}/${project.ownerSlug}/${project.projectSlug}`
51 : "";
52 
53 const handleSelectProvider = (provider: WebhookProvider) => {
54 if (configuredProviders.includes(provider)) return;
55 setSelectedProvider(provider);
56 const entry = webhookProviderCatalog[provider];
57 setInstanceUrl(
58 entry.configMode === "forbidden"
59 ? ""
60 : inferWebhookInstanceUrl(provider, project.repoUrl) || entry.defaultInstanceUrl || "",
61 );
62 setStep(2);
63 };
64 
65 const handleCopyUrl = () => {
66 void navigator.clipboard.writeText(webhookUrl).then(() => {
67 setCopiedUrl(true);
68 pushToast({ tone: "success", title: "Copied", message: "Webhook URL copied to clipboard." });
69 setTimeout(() => setCopiedUrl(false), 2000);
70 });
71 };
72 
73 const handleCreate = () => {
74 if (!selectedProvider) return;
75 
76 const entry = webhookProviderCatalog[selectedProvider];
77 const trimmedInstanceUrl = instanceUrl.trim();
78 if (entry.configMode === "required" && trimmedInstanceUrl.length === 0) {
79 setError(`${entry.displayName} webhooks require an instance URL.`);
80 return;
81 }
82 
83 setCreating(true);
84 setError(null);
85 
86 const config =
87 entry.configMode === "forbidden"
88 ? undefined
89 : trimmedInstanceUrl.length > 0
90 ? { instanceUrl: trimmedInstanceUrl }
91 : null;
92 
93 void getApiClient()
94 .createWebhook(projectId, selectedProvider, {
95 enabled: true,
96 ...(config === undefined ? {} : { config }),
97 })
98 .then((response) => {
99 onCreated(selectedProvider, response.generatedSecret, webhookUrl);
100 handleClose();
101 })
102 .catch((reason: unknown) => {
103 setError(formatApiError(reason));
104 })
105 .finally(() => {
106 setCreating(false);
107 });
108 };
109 
110 const handleBack = () => {
111 setStep(1);
112 setSelectedProvider(null);
113 setInstanceUrl("");
114 setError(null);
115 setCopiedUrl(false);
116 };
117 
118 if (step === 2 && catalog && selectedProvider) {
119 const instanceUrlRequired = catalog.configMode === "required";
120 
121 return (
122 <Dialog
123 open={open}
124 onClose={handleClose}
125 title={`Configure ${catalog.displayName} Webhook`}
126 icon={getWebhookProviderIcon(selectedProvider)}
127 >
128 <div className="space-y-5">
129 {catalog.configMode !== "forbidden" ? (
130 <Input
131 label="Instance URL"
132 helperText={`The base URL of your ${catalog.displayName} instance.${instanceUrlRequired ? " This field is required." : ""}`}
133 value={instanceUrl}
134 onChange={(e) => setInstanceUrl(e.target.value)}
135 placeholder={catalog.defaultInstanceUrl ?? "https://example.com"}
136 required={instanceUrlRequired}
137 />
138 ) : null}
139
140 <div>
141 <div className="flex items-center gap-2">
142 <p className="mb-2 text-xs uppercase tracking-[0.18em] text-zinc-500">Webhook URL</p>
143 <button
144 type="button"
145 className="mb-2 rounded-md p-1 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
146 onClick={handleCopyUrl}
147 >
148 {copiedUrl ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
149 </button>
150 </div>
151 <div className="rounded-xl border border-zinc-700/60 bg-zinc-800/80 p-3">
152 <p className="break-all font-mono text-sm text-accent-300">{webhookUrl}</p>
153 </div>
154 </div>
155
156 <div>
157 <p className="mb-2 text-xs uppercase tracking-[0.18em] text-zinc-500">Setup Instructions</p>
158 <ol className="list-inside list-decimal space-y-1 text-sm text-zinc-400">
159 {catalog.setupInstructions.map((instruction) => (
160 <li key={instruction}>{instruction}</li>
161 ))}
162 </ol>
163 </div>
164
165 <a
166 href={catalog.docsUrl}
167 target="_blank"
168 rel="noopener noreferrer"
169 className="inline-flex items-center gap-1 text-sm text-accent-400 hover:text-accent-300"
170 >
171 Provider documentation <ExternalLink className="h-3 w-3" />
172 </a>
173
174 {error ? <ErrorBanner message={error} /> : null}
175
176 <div className="flex items-center justify-end gap-3">
177 <Button variant="secondary" onClick={handleBack}>
178 Back
179 </Button>
180 <Button
181 variant="primary"
182 loading={creating}
183 disabled={catalog.configMode === "required" && instanceUrl.trim().length === 0}
184 onClick={handleCreate}
185 >
186 Create
187 </Button>
188 </div>
189 </div>
190 </Dialog>
191 );
192 }
193 
194 return (
195 <Dialog
196 open={open}
197 onClose={handleClose}
198 title="Add Webhook"
199 description="Choose a webhook provider"
200 icon={<Webhook className="h-5 w-5" />}
201 >
202 <div className="space-y-3">
203 {webhookProviderCatalogList.map((entry) => {
204 const isConfigured = configuredProviders.includes(entry.provider);
205 const isRecommended = entry.provider === recommended;
206
207 return (
208 <button
209 key={entry.provider}
210 type="button"
211 disabled={isConfigured}
212 onClick={() => handleSelectProvider(entry.provider)}
213 className={[
214 "w-full rounded-xl border p-4 text-left transition-colors",
215 isConfigured
216 ? "cursor-not-allowed opacity-50"
217 : isRecommended
218 ? "border-accent-500/30 bg-accent-500/5"
219 : "border-zinc-800/60 bg-zinc-900/30 hover:border-zinc-700 hover:bg-zinc-800/50",
220 ].join(" ")}
221 >
222 <div className="flex items-center gap-2">
223 {getWebhookProviderIcon(entry.provider)}
224 <span className="text-sm font-medium text-zinc-200">{entry.displayName}</span>
225 {isRecommended ? <Badge variant="accent">Recommended</Badge> : null}
226 {isConfigured ? <Badge>Configured</Badge> : null}
227 </div>
228 <p className="mt-1.5 text-xs text-zinc-500">
229 {entry.verificationKind === "hmac-sha256" ? "HMAC-SHA256" : "Shared Secret"}
230 </p>
231 </button>
232 );
233 })}
234 </div>
235 </Dialog>
236 );
237};