@chrysb/alphaclaw 0.8.0 → 0.8.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/public/js/app.js +100 -83
  2. package/lib/public/js/components/agents-tab/agent-pairing-section.js +47 -12
  3. package/lib/public/js/components/channels.js +14 -17
  4. package/lib/public/js/components/envars.js +42 -6
  5. package/lib/public/js/components/features.js +6 -12
  6. package/lib/public/js/components/general/use-general-tab.js +10 -5
  7. package/lib/public/js/components/google/use-gmail-watch.js +22 -18
  8. package/lib/public/js/components/google/use-google-accounts.js +23 -23
  9. package/lib/public/js/components/models-tab/use-models.js +20 -4
  10. package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +2 -2
  11. package/lib/public/js/components/nodes-tab/use-nodes-tab.js +13 -9
  12. package/lib/public/js/components/routes/webhooks-route.js +1 -1
  13. package/lib/public/js/components/webhooks/create-webhook-modal/index.js +176 -0
  14. package/lib/public/js/components/webhooks/helpers.js +106 -0
  15. package/lib/public/js/components/webhooks/index.js +148 -0
  16. package/lib/public/js/components/webhooks/request-history/index.js +241 -0
  17. package/lib/public/js/components/webhooks/request-history/use-request-history.js +167 -0
  18. package/lib/public/js/components/webhooks/webhook-detail/index.js +374 -0
  19. package/lib/public/js/components/webhooks/webhook-detail/use-webhook-detail.js +261 -0
  20. package/lib/public/js/components/webhooks/webhook-list/index.js +96 -0
  21. package/lib/public/js/components/webhooks/webhook-list/use-webhook-list.js +30 -0
  22. package/lib/public/js/hooks/use-app-shell-controller.js +59 -6
  23. package/lib/public/js/hooks/use-cached-fetch.js +63 -0
  24. package/lib/public/js/hooks/usePolling.js +45 -7
  25. package/lib/public/js/lib/api-cache.js +88 -0
  26. package/lib/public/js/lib/api.js +64 -1
  27. package/lib/server/db/webhooks/index.js +144 -0
  28. package/lib/server/db/webhooks/schema.js +13 -0
  29. package/lib/server/init/register-server-routes.js +21 -0
  30. package/lib/server/oauth-callback-middleware.js +34 -0
  31. package/lib/server/routes/proxy.js +2 -0
  32. package/lib/server/routes/system.js +50 -2
  33. package/lib/server/routes/webhooks.js +126 -18
  34. package/lib/server/webhook-middleware.js +6 -1
  35. package/lib/server.js +12 -0
  36. package/package.json +1 -1
  37. package/lib/public/js/components/webhooks.js +0 -1259
@@ -0,0 +1,374 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "../../action-button.js";
4
+ import { Badge } from "../../badge.js";
5
+ import { ConfirmDialog } from "../../confirm-dialog.js";
6
+ import { showToast } from "../../toast.js";
7
+ import { formatDateTime } from "../helpers.js";
8
+ import { RequestHistory } from "../request-history/index.js";
9
+ import { useWebhookDetail } from "./use-webhook-detail.js";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ export const WebhookDetail = ({
14
+ selectedHookName = "",
15
+ onBackToList = () => {},
16
+ onRestartRequired = () => {},
17
+ onOpenFile = () => {},
18
+ }) => {
19
+ const { state, actions } = useWebhookDetail({
20
+ selectedHookName,
21
+ onBackToList,
22
+ onRestartRequired,
23
+ });
24
+
25
+ const {
26
+ authMode,
27
+ selectedWebhook,
28
+ selectedWebhookManaged,
29
+ selectedDeliveryAgentName,
30
+ selectedDeliveryChannel,
31
+ webhookUrl,
32
+ oauthCallbackUrl,
33
+ hasOauthCallback,
34
+ webhookUrlWithQueryToken,
35
+ authHeaderValue,
36
+ bearerTokenValue,
37
+ effectiveAuthMode,
38
+ activeCurlCommand,
39
+ deleting,
40
+ showDeleteConfirm,
41
+ deleteTransformDir,
42
+ sendingTestWebhook,
43
+ rotatingOauthCallback,
44
+ showRotateOauthConfirm,
45
+ } = state;
46
+
47
+ return html`
48
+ <div class="space-y-4">
49
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-4">
50
+ <div>
51
+ <h2 class="font-semibold text-sm">
52
+ ${selectedWebhook?.path || `/hooks/${selectedHookName}`}
53
+ </h2>
54
+ </div>
55
+
56
+ ${hasOauthCallback
57
+ ? null
58
+ : html`<div class="bg-black/20 border border-border rounded-lg p-3 space-y-4">
59
+ ${selectedWebhookManaged
60
+ ? null
61
+ : html`
62
+ <div class="space-y-2">
63
+ <p class="text-xs text-gray-500">Auth mode</p>
64
+ <div class="flex items-center gap-2">
65
+ <button
66
+ class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
67
+ "headers"
68
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
69
+ : "border-border text-gray-400 hover:text-gray-200"}"
70
+ onclick=${() => actions.setAuthMode("headers")}
71
+ >
72
+ Headers
73
+ </button>
74
+ <button
75
+ class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
76
+ "query"
77
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
78
+ : "border-border text-gray-400 hover:text-gray-200"}"
79
+ onclick=${() => actions.setAuthMode("query")}
80
+ >
81
+ Query string
82
+ </button>
83
+ </div>
84
+ </div>
85
+ `}
86
+ <div class="space-y-2">
87
+ <p class="text-xs text-gray-500">Webhook URL</p>
88
+ <div class="flex items-center gap-2">
89
+ <input
90
+ type="text"
91
+ readonly
92
+ value=${effectiveAuthMode === "query"
93
+ ? webhookUrlWithQueryToken
94
+ : webhookUrl}
95
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
96
+ />
97
+ <button
98
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
99
+ onclick=${async () => {
100
+ try {
101
+ await navigator.clipboard.writeText(
102
+ effectiveAuthMode === "query"
103
+ ? webhookUrlWithQueryToken
104
+ : webhookUrl,
105
+ );
106
+ showToast("Webhook URL copied", "success");
107
+ } catch {
108
+ showToast("Could not copy URL", "error");
109
+ }
110
+ }}
111
+ >
112
+ Copy
113
+ </button>
114
+ </div>
115
+ </div>
116
+ ${selectedWebhookManaged
117
+ ? null
118
+ : effectiveAuthMode === "headers"
119
+ ? html`
120
+ <div class="space-y-2">
121
+ <p class="text-xs text-gray-500">Auth headers</p>
122
+ <div class="flex items-center gap-2">
123
+ <input
124
+ type="text"
125
+ readonly
126
+ value=${authHeaderValue}
127
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
128
+ />
129
+ <button
130
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
131
+ onclick=${async () => {
132
+ try {
133
+ await navigator.clipboard.writeText(
134
+ bearerTokenValue,
135
+ );
136
+ showToast("Bearer token copied", "success");
137
+ } catch {
138
+ showToast("Could not copy bearer token", "error");
139
+ }
140
+ }}
141
+ >
142
+ Copy
143
+ </button>
144
+ </div>
145
+ </div>
146
+ `
147
+ : html`
148
+ <p class="text-xs text-yellow-300">
149
+ Always use auth headers when possible. Query string is
150
+ less secure.
151
+ </p>
152
+ `}
153
+ </div>`}
154
+
155
+ ${selectedWebhookManaged || !hasOauthCallback
156
+ ? null
157
+ : html`
158
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
159
+ <div class="flex items-center gap-2">
160
+ <p class="text-xs text-gray-500">OAuth Callback URL</p>
161
+ ${hasOauthCallback
162
+ ? html`<${Badge} tone="neutral">OAuth alias</${Badge}>`
163
+ : null}
164
+ </div>
165
+ <div class="flex items-center gap-2">
166
+ <input
167
+ type="text"
168
+ readonly
169
+ value=${hasOauthCallback ? oauthCallbackUrl : "Not enabled"}
170
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
171
+ />
172
+ ${hasOauthCallback
173
+ ? html`
174
+ <button
175
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
176
+ onclick=${async () => {
177
+ try {
178
+ await navigator.clipboard.writeText(
179
+ oauthCallbackUrl,
180
+ );
181
+ showToast("OAuth callback URL copied", "success");
182
+ } catch {
183
+ showToast("Could not copy URL", "error");
184
+ }
185
+ }}
186
+ >
187
+ Copy
188
+ </button>
189
+ `
190
+ : null}
191
+ </div>
192
+ <div class="flex items-center justify-start gap-3 flex-wrap">
193
+ <div class="flex items-center gap-2">
194
+ <button
195
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60"
196
+ onclick=${() => {
197
+ if (rotatingOauthCallback) return;
198
+ actions.setShowRotateOauthConfirm(true);
199
+ }}
200
+ disabled=${rotatingOauthCallback}
201
+ >
202
+ ${rotatingOauthCallback ? "Rotating..." : "Rotate"}
203
+ </button>
204
+ </div>
205
+ <p class="text-xs text-yellow-300">
206
+ Keep this URL private. Rotate if exposed.
207
+ </p>
208
+ </div>
209
+ </div>
210
+ `}
211
+
212
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
213
+ <p class="text-xs text-gray-500">Deliver to</p>
214
+ <p class="text-xs text-gray-200 font-mono ">
215
+ ${selectedDeliveryAgentName}${" "}
216
+ <span class="text-xs text-gray-500 font-mono"
217
+ >(${selectedDeliveryChannel})</span
218
+ >
219
+ </p>
220
+ </div>
221
+
222
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
223
+ <p class="text-xs text-gray-500">Test webhook</p>
224
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
225
+ <input
226
+ type="text"
227
+ readonly
228
+ value=${activeCurlCommand}
229
+ class="h-8 w-full sm:flex-1 sm:min-w-0 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono overflow-x-auto scrollbar-hidden"
230
+ />
231
+ <div class="grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center">
232
+ <button
233
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0"
234
+ onclick=${async () => {
235
+ try {
236
+ await navigator.clipboard.writeText(activeCurlCommand);
237
+ showToast("curl command copied", "success");
238
+ } catch {
239
+ showToast("Could not copy curl command", "error");
240
+ }
241
+ }}
242
+ >
243
+ Copy
244
+ </button>
245
+ <button
246
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0 disabled:opacity-60"
247
+ onclick=${actions.handleSendTestWebhook}
248
+ disabled=${sendingTestWebhook}
249
+ >
250
+ ${sendingTestWebhook ? "Sending..." : "Send"}
251
+ </button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ <div class="bg-black/20 border border-border rounded-lg p-3">
257
+ <div class="flex items-center gap-2 text-xs text-gray-300">
258
+ <span class="text-gray-500">Transform:</span>
259
+ ${selectedWebhook?.transformPath
260
+ ? html`<button
261
+ type="button"
262
+ class="ac-tip-link flex-1 min-w-0 truncate block text-left font-mono"
263
+ title=${selectedWebhook.transformPath}
264
+ onclick=${() => onOpenFile(selectedWebhook.transformPath)}
265
+ >
266
+ ${selectedWebhook.transformPath}
267
+ </button>`
268
+ : html`<code class="flex-1 min-w-0 truncate block">—</code>`}
269
+ <span
270
+ class=${`ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded border font-sans ${
271
+ selectedWebhook?.transformExists
272
+ ? "border-green-500/30 text-green-300 bg-green-500/10"
273
+ : "border-yellow-500/30 text-yellow-300 bg-yellow-500/10"
274
+ }`}
275
+ >
276
+ <span class="font-sans text-sm leading-none">
277
+ ${selectedWebhook?.transformExists ? "✓" : "!"}
278
+ </span>
279
+ ${selectedWebhook?.transformExists ? null : html`<span>missing</span>`}
280
+ </span>
281
+ </div>
282
+ </div>
283
+
284
+ <div class="flex items-center justify-between gap-3">
285
+ <p class="text-xs text-gray-600">
286
+ Created: ${formatDateTime(selectedWebhook?.createdAt)}
287
+ </p>
288
+ ${selectedWebhookManaged
289
+ ? null
290
+ : html`<${ActionButton}
291
+ onClick=${() => {
292
+ if (deleting) return;
293
+ actions.setDeleteTransformDir(true);
294
+ actions.setShowDeleteConfirm(true);
295
+ }}
296
+ disabled=${deleting}
297
+ loading=${deleting}
298
+ tone="danger"
299
+ size="sm"
300
+ idleLabel="Delete"
301
+ loadingLabel="Deleting..."
302
+ className="shrink-0 px-2.5 py-1"
303
+ />`}
304
+ </div>
305
+ </div>
306
+
307
+ ${selectedWebhookManaged
308
+ ? html`
309
+ <div class="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
310
+ <p class="text-xs text-yellow-200">
311
+ This webhook is managed by Gmail Watch setup and cannot be
312
+ deleted or edited from this page.
313
+ </p>
314
+ </div>
315
+ `
316
+ : null}
317
+ <${RequestHistory}
318
+ selectedHookName=${selectedHookName}
319
+ selectedWebhook=${selectedWebhook}
320
+ effectiveAuthMode=${effectiveAuthMode}
321
+ webhookUrl=${webhookUrl}
322
+ webhookUrlWithQueryToken=${webhookUrlWithQueryToken}
323
+ bearerTokenValue=${bearerTokenValue}
324
+ />
325
+ <${ConfirmDialog}
326
+ visible=${showRotateOauthConfirm &&
327
+ !!selectedHookName &&
328
+ !selectedWebhookManaged &&
329
+ hasOauthCallback}
330
+ title="Rotate OAuth callback?"
331
+ message="Rotating will generate a new callback URL and immediately invalidate the current URL."
332
+ confirmLabel="Rotate callback URL"
333
+ confirmLoadingLabel="Rotating..."
334
+ confirmLoading=${rotatingOauthCallback}
335
+ cancelLabel="Cancel"
336
+ onCancel=${() => {
337
+ if (rotatingOauthCallback) return;
338
+ actions.setShowRotateOauthConfirm(false);
339
+ }}
340
+ onConfirm=${actions.handleRotateOauthCallback}
341
+ />
342
+ <${ConfirmDialog}
343
+ visible=${showDeleteConfirm &&
344
+ !!selectedHookName &&
345
+ !selectedWebhookManaged}
346
+ title="Delete webhook?"
347
+ message=${`This removes "/hooks/${selectedHookName}" from openclaw.json.`}
348
+ details=${html`
349
+ <div class="rounded-lg border border-border bg-black/20 p-3">
350
+ <label class="flex items-center gap-2 text-xs text-gray-300 select-none">
351
+ <input
352
+ type="checkbox"
353
+ checked=${deleteTransformDir}
354
+ onInput=${(event) =>
355
+ actions.setDeleteTransformDir(!!event.target.checked)}
356
+ />
357
+ Also delete <code>hooks/transforms/${selectedHookName}</code>
358
+ </label>
359
+ </div>
360
+ `}
361
+ confirmLabel="Delete webhook"
362
+ confirmLoadingLabel="Deleting..."
363
+ confirmLoading=${deleting}
364
+ cancelLabel="Cancel"
365
+ onCancel=${() => {
366
+ if (deleting) return;
367
+ actions.setDeleteTransformDir(true);
368
+ actions.setShowDeleteConfirm(false);
369
+ }}
370
+ onConfirm=${actions.handleDeleteConfirmed}
371
+ />
372
+ </div>
373
+ `;
374
+ };
@@ -0,0 +1,261 @@
1
+ import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ deleteWebhook,
4
+ fetchAgents,
5
+ fetchWebhookDetail,
6
+ rotateWebhookOauthCallback,
7
+ } from "../../../lib/api.js";
8
+ import { usePolling } from "../../../hooks/usePolling.js";
9
+ import { showToast } from "../../toast.js";
10
+ import { formatAgentFallbackName } from "../helpers.js";
11
+
12
+ export const useWebhookDetail = ({
13
+ selectedHookName = "",
14
+ onBackToList = () => {},
15
+ onRestartRequired = () => {},
16
+ }) => {
17
+ const [authMode, setAuthMode] = useState("headers");
18
+ const [deleting, setDeleting] = useState(false);
19
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
20
+ const [deleteTransformDir, setDeleteTransformDir] = useState(true);
21
+ const [rotatingOauthCallback, setRotatingOauthCallback] = useState(false);
22
+ const [showRotateOauthConfirm, setShowRotateOauthConfirm] = useState(false);
23
+ const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
24
+
25
+ const detailPoll = usePolling(
26
+ async () => {
27
+ if (!selectedHookName) return null;
28
+ const data = await fetchWebhookDetail(selectedHookName);
29
+ return data.webhook || null;
30
+ },
31
+ 10000,
32
+ { enabled: !!selectedHookName },
33
+ );
34
+
35
+ const agentsPoll = usePolling(fetchAgents, 20000);
36
+ const agents = Array.isArray(agentsPoll.data?.agents) ? agentsPoll.data.agents : [];
37
+ const agentNameById = useMemo(
38
+ () =>
39
+ new Map(
40
+ agents.map((agent) => [
41
+ String(agent?.id || "").trim(),
42
+ String(agent?.name || "").trim() || formatAgentFallbackName(agent?.id),
43
+ ]),
44
+ ),
45
+ [agents],
46
+ );
47
+
48
+ const selectedWebhook = detailPoll.data;
49
+ const selectedWebhookManaged = Boolean(selectedWebhook?.managed);
50
+ const selectedDeliveryAgentId =
51
+ String(selectedWebhook?.agentId || "main").trim() || "main";
52
+ const selectedDeliveryAgentName =
53
+ agentNameById.get(selectedDeliveryAgentId) ||
54
+ formatAgentFallbackName(selectedDeliveryAgentId);
55
+ const selectedDeliveryChannel =
56
+ String(selectedWebhook?.channel || "last").trim() || "last";
57
+
58
+ const webhookUrl = selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;
59
+ const oauthCallbackUrl = String(selectedWebhook?.oauthCallbackUrl || "").trim();
60
+ const hasOauthCallback = !!oauthCallbackUrl;
61
+ const webhookUrlWithQueryToken =
62
+ selectedWebhook?.queryStringUrl ||
63
+ `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=<WEBHOOK_TOKEN>`;
64
+
65
+ const derivedTokenFromQuery = useMemo(() => {
66
+ try {
67
+ const parsed = new URL(webhookUrlWithQueryToken);
68
+ return String(parsed.searchParams.get("token") || "").trim();
69
+ } catch {
70
+ return "";
71
+ }
72
+ }, [webhookUrlWithQueryToken]);
73
+
74
+ const authHeaderValue =
75
+ selectedWebhook?.authHeaderValue ||
76
+ (derivedTokenFromQuery
77
+ ? `Authorization: Bearer ${derivedTokenFromQuery}`
78
+ : "Authorization: Bearer <WEBHOOK_TOKEN>");
79
+ const bearerTokenValue = authHeaderValue.startsWith("Authorization: ")
80
+ ? authHeaderValue.slice("Authorization: ".length)
81
+ : authHeaderValue;
82
+
83
+ const webhookTestPayload = useMemo(() => {
84
+ if (
85
+ String(selectedHookName || "")
86
+ .trim()
87
+ .toLowerCase() === "gmail"
88
+ ) {
89
+ return {
90
+ payload: {
91
+ account: "test@gmail.com",
92
+ messages: [
93
+ {
94
+ id: "test-message-1",
95
+ from: "alerts@example.com",
96
+ to: ["test@gmail.com"],
97
+ subject: "Test Gmail webhook event",
98
+ snippet:
99
+ "This is a simulated Gmail message payload for webhook testing.",
100
+ receivedAt: new Date().toISOString(),
101
+ },
102
+ ],
103
+ },
104
+ };
105
+ }
106
+ return {
107
+ source: "manual-test",
108
+ message: `This is a test of the ${selectedHookName || "webhook"} webhook.`,
109
+ };
110
+ }, [selectedHookName]);
111
+
112
+ const webhookTestPayloadJson = JSON.stringify(webhookTestPayload);
113
+ const curlCommandHeaders =
114
+ `curl -X POST "${webhookUrl}" ` +
115
+ `-H "Content-Type: application/json" ` +
116
+ `-H "${authHeaderValue}" ` +
117
+ `-d '${webhookTestPayloadJson}'`;
118
+ const curlCommandQuery =
119
+ `curl -X POST "${webhookUrlWithQueryToken}" ` +
120
+ `-H "Content-Type: application/json" ` +
121
+ `-d '${webhookTestPayloadJson}'`;
122
+
123
+ const effectiveAuthMode = selectedWebhookManaged ? "headers" : authMode;
124
+ const activeCurlCommand =
125
+ effectiveAuthMode === "query" ? curlCommandQuery : curlCommandHeaders;
126
+
127
+ const refreshDetail = useCallback(() => {
128
+ detailPoll.refresh();
129
+ agentsPoll.refresh();
130
+ }, [agentsPoll.refresh, detailPoll.refresh]);
131
+
132
+ const handleSendTestWebhook = useCallback(async () => {
133
+ if (!selectedHookName || sendingTestWebhook) return;
134
+ setSendingTestWebhook(true);
135
+ const requestUrl =
136
+ effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
137
+ const headers = { "Content-Type": "application/json" };
138
+ if (effectiveAuthMode === "headers") {
139
+ headers.Authorization = bearerTokenValue;
140
+ }
141
+ try {
142
+ const response = await fetch(requestUrl, {
143
+ method: "POST",
144
+ headers,
145
+ body: webhookTestPayloadJson,
146
+ });
147
+ const bodyText = await response.text();
148
+ let body = null;
149
+ try {
150
+ body = bodyText ? JSON.parse(bodyText) : null;
151
+ } catch {
152
+ body = null;
153
+ }
154
+ const errorMessage =
155
+ body?.ok === false
156
+ ? body?.error || "Webhook rejected"
157
+ : !response.ok
158
+ ? body?.error || bodyText || `HTTP ${response.status}`
159
+ : "";
160
+ if (errorMessage) {
161
+ showToast(`Test webhook failed: ${errorMessage}`, "error");
162
+ return;
163
+ }
164
+ showToast("Test webhook sent", "success");
165
+ } catch (err) {
166
+ showToast(err.message || "Could not send test webhook", "error");
167
+ } finally {
168
+ setSendingTestWebhook(false);
169
+ }
170
+ }, [
171
+ bearerTokenValue,
172
+ effectiveAuthMode,
173
+ selectedHookName,
174
+ sendingTestWebhook,
175
+ webhookTestPayloadJson,
176
+ webhookUrl,
177
+ webhookUrlWithQueryToken,
178
+ ]);
179
+
180
+ const handleDeleteConfirmed = useCallback(async () => {
181
+ if (!selectedHookName || deleting) return;
182
+ setDeleting(true);
183
+ try {
184
+ const data = await deleteWebhook(selectedHookName, {
185
+ deleteTransformDir,
186
+ });
187
+ if (data.restartRequired) onRestartRequired(true);
188
+ onBackToList();
189
+ setShowDeleteConfirm(false);
190
+ setDeleteTransformDir(true);
191
+ showToast("Webhook removed", "success");
192
+ if (data.deletedTransformDir) {
193
+ showToast("Transform directory deleted", "success");
194
+ }
195
+ if (data.syncWarning) {
196
+ showToast(`Deleted, but git-sync failed: ${data.syncWarning}`, "warning");
197
+ }
198
+ refreshDetail();
199
+ } catch (err) {
200
+ showToast(err.message || "Could not delete webhook", "error");
201
+ } finally {
202
+ setDeleting(false);
203
+ }
204
+ }, [
205
+ deleteTransformDir,
206
+ deleting,
207
+ onBackToList,
208
+ onRestartRequired,
209
+ refreshDetail,
210
+ selectedHookName,
211
+ ]);
212
+
213
+ const handleRotateOauthCallback = useCallback(async () => {
214
+ if (!selectedHookName || rotatingOauthCallback) return;
215
+ setRotatingOauthCallback(true);
216
+ try {
217
+ await rotateWebhookOauthCallback(selectedHookName);
218
+ showToast("OAuth callback rotated", "success");
219
+ setShowRotateOauthConfirm(false);
220
+ refreshDetail();
221
+ } catch (err) {
222
+ showToast(err.message || "Could not rotate OAuth callback", "error");
223
+ } finally {
224
+ setRotatingOauthCallback(false);
225
+ }
226
+ }, [refreshDetail, rotatingOauthCallback, selectedHookName]);
227
+
228
+ return {
229
+ state: {
230
+ authMode,
231
+ selectedWebhook,
232
+ selectedWebhookManaged,
233
+ selectedDeliveryAgentName,
234
+ selectedDeliveryChannel,
235
+ webhookUrl,
236
+ oauthCallbackUrl,
237
+ hasOauthCallback,
238
+ webhookUrlWithQueryToken,
239
+ authHeaderValue,
240
+ bearerTokenValue,
241
+ effectiveAuthMode,
242
+ activeCurlCommand,
243
+ deleting,
244
+ showDeleteConfirm,
245
+ deleteTransformDir,
246
+ rotatingOauthCallback,
247
+ showRotateOauthConfirm,
248
+ sendingTestWebhook,
249
+ },
250
+ actions: {
251
+ refreshDetail,
252
+ setAuthMode,
253
+ setShowDeleteConfirm,
254
+ setDeleteTransformDir,
255
+ setShowRotateOauthConfirm,
256
+ handleDeleteConfirmed,
257
+ handleRotateOauthCallback,
258
+ handleSendTestWebhook,
259
+ },
260
+ };
261
+ };