@orthacms/webhooks-admin 0.5.0

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +12 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +9 -0
  6. package/dist/lib/application/useContentTypeOptions/index.d.ts +18 -0
  7. package/dist/lib/application/useContentTypeOptions/index.d.ts.map +1 -0
  8. package/dist/lib/application/useContentTypeOptions/index.js +40 -0
  9. package/dist/lib/application/useWebhookDeliveries/index.d.ts +19 -0
  10. package/dist/lib/application/useWebhookDeliveries/index.d.ts.map +1 -0
  11. package/dist/lib/application/useWebhookDeliveries/index.js +48 -0
  12. package/dist/lib/application/useWebhookEndpoints/index.d.ts +12 -0
  13. package/dist/lib/application/useWebhookEndpoints/index.d.ts.map +1 -0
  14. package/dist/lib/application/useWebhookEndpoints/index.js +26 -0
  15. package/dist/lib/application/useWebhookEvents/index.d.ts +9 -0
  16. package/dist/lib/application/useWebhookEvents/index.d.ts.map +1 -0
  17. package/dist/lib/application/useWebhookEvents/index.js +20 -0
  18. package/dist/lib/application/useWebhookMutations/index.d.ts +25 -0
  19. package/dist/lib/application/useWebhookMutations/index.d.ts.map +1 -0
  20. package/dist/lib/application/useWebhookMutations/index.js +79 -0
  21. package/dist/lib/application/useWorkspaceOptions/index.d.ts +9 -0
  22. package/dist/lib/application/useWorkspaceOptions/index.d.ts.map +1 -0
  23. package/dist/lib/application/useWorkspaceOptions/index.js +19 -0
  24. package/dist/lib/domain/contentTypeChoices/index.d.ts +65 -0
  25. package/dist/lib/domain/contentTypeChoices/index.d.ts.map +1 -0
  26. package/dist/lib/domain/contentTypeChoices/index.js +70 -0
  27. package/dist/lib/domain/headerRules/index.d.ts +37 -0
  28. package/dist/lib/domain/headerRules/index.d.ts.map +1 -0
  29. package/dist/lib/domain/headerRules/index.js +66 -0
  30. package/dist/lib/domain/types/contentTypeOption/index.d.ts +14 -0
  31. package/dist/lib/domain/types/contentTypeOption/index.d.ts.map +1 -0
  32. package/dist/lib/domain/types/contentTypeOption/index.js +1 -0
  33. package/dist/lib/domain/types/webhook/index.d.ts +102 -0
  34. package/dist/lib/domain/types/webhook/index.d.ts.map +1 -0
  35. package/dist/lib/domain/types/webhook/index.js +18 -0
  36. package/dist/lib/domain/types/workspaceOption/index.d.ts +16 -0
  37. package/dist/lib/domain/types/workspaceOption/index.d.ts.map +1 -0
  38. package/dist/lib/domain/types/workspaceOption/index.js +1 -0
  39. package/dist/lib/infrastructure/httpWebhookGateway/index.d.ts +11 -0
  40. package/dist/lib/infrastructure/httpWebhookGateway/index.d.ts.map +1 -0
  41. package/dist/lib/infrastructure/httpWebhookGateway/index.js +138 -0
  42. package/dist/lib/infrastructure/serverMessageOf/index.d.ts +16 -0
  43. package/dist/lib/infrastructure/serverMessageOf/index.d.ts.map +1 -0
  44. package/dist/lib/infrastructure/serverMessageOf/index.js +24 -0
  45. package/dist/lib/infrastructure/webhookGateway/index.d.ts +62 -0
  46. package/dist/lib/infrastructure/webhookGateway/index.d.ts.map +1 -0
  47. package/dist/lib/infrastructure/webhookGateway/index.js +1 -0
  48. package/dist/lib/infrastructure/webhookMapper/index.d.ts +104 -0
  49. package/dist/lib/infrastructure/webhookMapper/index.d.ts.map +1 -0
  50. package/dist/lib/infrastructure/webhookMapper/index.js +49 -0
  51. package/dist/lib/infrastructure/webhooksKeys/index.d.ts +35 -0
  52. package/dist/lib/infrastructure/webhooksKeys/index.d.ts.map +1 -0
  53. package/dist/lib/infrastructure/webhooksKeys/index.js +23 -0
  54. package/dist/lib/presentation/components/DeliveryStatusBadge/index.d.ts +6 -0
  55. package/dist/lib/presentation/components/DeliveryStatusBadge/index.d.ts.map +1 -0
  56. package/dist/lib/presentation/components/DeliveryStatusBadge/index.js +30 -0
  57. package/dist/lib/presentation/components/RevealWebhookSecretDialog/index.d.ts +25 -0
  58. package/dist/lib/presentation/components/RevealWebhookSecretDialog/index.d.ts.map +1 -0
  59. package/dist/lib/presentation/components/RevealWebhookSecretDialog/index.js +107 -0
  60. package/dist/lib/presentation/components/WebhookDeliveriesPanel/DeliveryDetailSheet/index.d.ts +19 -0
  61. package/dist/lib/presentation/components/WebhookDeliveriesPanel/DeliveryDetailSheet/index.d.ts.map +1 -0
  62. package/dist/lib/presentation/components/WebhookDeliveriesPanel/DeliveryDetailSheet/index.js +72 -0
  63. package/dist/lib/presentation/components/WebhookDeliveriesPanel/index.d.ts +12 -0
  64. package/dist/lib/presentation/components/WebhookDeliveriesPanel/index.d.ts.map +1 -0
  65. package/dist/lib/presentation/components/WebhookDeliveriesPanel/index.js +132 -0
  66. package/dist/lib/presentation/components/WebhookForm/index.d.ts +46 -0
  67. package/dist/lib/presentation/components/WebhookForm/index.d.ts.map +1 -0
  68. package/dist/lib/presentation/components/WebhookForm/index.js +412 -0
  69. package/dist/lib/presentation/components/WebhooksEmpty/index.d.ts +5 -0
  70. package/dist/lib/presentation/components/WebhooksEmpty/index.d.ts.map +1 -0
  71. package/dist/lib/presentation/components/WebhooksEmpty/index.js +23 -0
  72. package/dist/lib/presentation/components/WebhooksNoAccess/index.d.ts +3 -0
  73. package/dist/lib/presentation/components/WebhooksNoAccess/index.d.ts.map +1 -0
  74. package/dist/lib/presentation/components/WebhooksNoAccess/index.js +18 -0
  75. package/dist/lib/presentation/components/WebhooksSkeleton/index.d.ts +20 -0
  76. package/dist/lib/presentation/components/WebhooksSkeleton/index.d.ts.map +1 -0
  77. package/dist/lib/presentation/components/WebhooksSkeleton/index.js +45 -0
  78. package/dist/lib/presentation/components/WebhooksTable/index.d.ts +12 -0
  79. package/dist/lib/presentation/components/WebhooksTable/index.d.ts.map +1 -0
  80. package/dist/lib/presentation/components/WebhooksTable/index.js +79 -0
  81. package/dist/lib/presentation/pages/WebhookDetailPage/index.d.ts +10 -0
  82. package/dist/lib/presentation/pages/WebhookDetailPage/index.d.ts.map +1 -0
  83. package/dist/lib/presentation/pages/WebhookDetailPage/index.js +191 -0
  84. package/dist/lib/presentation/pages/WebhookEditorPage/index.d.ts +28 -0
  85. package/dist/lib/presentation/pages/WebhookEditorPage/index.d.ts.map +1 -0
  86. package/dist/lib/presentation/pages/WebhookEditorPage/index.js +149 -0
  87. package/dist/lib/presentation/pages/WebhooksPage/index.d.ts +11 -0
  88. package/dist/lib/presentation/pages/WebhooksPage/index.d.ts.map +1 -0
  89. package/dist/lib/presentation/pages/WebhooksPage/index.js +65 -0
  90. package/dist/lib/utils/webhooksPlugin/index.d.ts +35 -0
  91. package/dist/lib/utils/webhooksPlugin/index.d.ts.map +1 -0
  92. package/dist/lib/utils/webhooksPlugin/index.js +88 -0
  93. package/package.json +46 -0
@@ -0,0 +1,412 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { defineMessages, useIntl } from 'react-intl';
4
+ import { Alert, AlertDescription, Button, Checkbox, ConfirmDialog, Input, InputField, Label, MultiSelect, Switch } from '@orthacms/design-system';
5
+ import { Plus, X } from 'lucide-react';
6
+ import { fromHeaderRows, rejectionFor, toHeaderRows } from '../../../domain/headerRules';
7
+ import { addContentTypeNames, contentTypeChoices, grantedContentTypes, parseContentTypeNames } from '../../../domain/contentTypeChoices';
8
+ const messages = defineMessages({
9
+ target: {
10
+ id: 'webhooks.form.target',
11
+ defaultMessage: 'Where deliveries go'
12
+ },
13
+ subscription: {
14
+ id: 'webhooks.form.subscription',
15
+ defaultMessage: 'What this endpoint receives'
16
+ },
17
+ name: { id: 'webhooks.form.name', defaultMessage: 'Name' },
18
+ namePlaceholder: {
19
+ id: 'webhooks.form.namePlaceholder',
20
+ defaultMessage: 'Rebuild the storefront'
21
+ },
22
+ url: { id: 'webhooks.form.url', defaultMessage: 'URL' },
23
+ urlHint: {
24
+ id: 'webhooks.form.urlHint',
25
+ defaultMessage: 'Must be https:// and reachable on the public internet.'
26
+ },
27
+ workspaces: {
28
+ id: 'webhooks.form.workspaces',
29
+ defaultMessage: 'Workspaces'
30
+ },
31
+ allWorkspaces: {
32
+ id: 'webhooks.form.allWorkspaces',
33
+ defaultMessage: 'All workspaces, including ones created later'
34
+ },
35
+ workspacesHint: {
36
+ id: 'webhooks.form.workspacesHint',
37
+ defaultMessage: 'A record that belongs to no workspace is only sent to endpoints that take them all.'
38
+ },
39
+ workspacesPlaceholder: {
40
+ id: 'webhooks.form.workspacesPlaceholder',
41
+ defaultMessage: 'Choose workspaces'
42
+ },
43
+ events: { id: 'webhooks.form.events', defaultMessage: 'Events' },
44
+ allEvents: {
45
+ id: 'webhooks.form.allEvents',
46
+ defaultMessage: 'Every event, including ones added later'
47
+ },
48
+ eventsPlaceholder: {
49
+ id: 'webhooks.form.eventsPlaceholder',
50
+ defaultMessage: 'Choose events'
51
+ },
52
+ contentTypes: {
53
+ id: 'webhooks.form.contentTypes',
54
+ defaultMessage: 'Content types'
55
+ },
56
+ allContentTypes: {
57
+ id: 'webhooks.form.allContentTypes',
58
+ defaultMessage: 'Every content type, including ones added later'
59
+ },
60
+ contentTypesPlaceholder: {
61
+ id: 'webhooks.form.contentTypesPlaceholder',
62
+ defaultMessage: 'Choose content types'
63
+ },
64
+ contentTypesHint: {
65
+ id: 'webhooks.form.contentTypesHint',
66
+ defaultMessage: 'Pick from the types this build defines, or add one by machine name — a type you are about to create can be subscribed to before it exists.'
67
+ },
68
+ noContentTypes: {
69
+ id: 'webhooks.form.noContentTypes',
70
+ defaultMessage: 'Nothing chosen yet, so this endpoint still receives every type.'
71
+ },
72
+ contentTypeSearch: {
73
+ id: 'webhooks.form.contentTypeSearch',
74
+ defaultMessage: 'Search, or type a machine name'
75
+ },
76
+ addContentType: {
77
+ id: 'webhooks.form.addContentType',
78
+ defaultMessage: 'Add “{name}”'
79
+ },
80
+ unknownContentType: {
81
+ id: 'webhooks.form.unknownContentType',
82
+ defaultMessage: '{name} (not defined here)'
83
+ },
84
+ contentTypesNeedWorkspace: {
85
+ id: 'webhooks.form.contentTypesNeedWorkspace',
86
+ defaultMessage: 'Choose a workspace first — which types exist depends on what those workspaces were granted.'
87
+ },
88
+ contentTypesNoneGranted: {
89
+ id: 'webhooks.form.contentTypesNoneGranted',
90
+ defaultMessage: 'The chosen workspaces were granted no content types, so there is nothing to pick from — a machine name can still be typed into the picker.'
91
+ },
92
+ contentTypesScoped: {
93
+ id: 'webhooks.form.contentTypesScoped',
94
+ defaultMessage: 'Listing only what the chosen workspaces were granted.'
95
+ },
96
+ status: { id: 'webhooks.form.status', defaultMessage: 'Status' },
97
+ enabled: { id: 'webhooks.form.enabled', defaultMessage: 'Enabled' },
98
+ disabled: { id: 'webhooks.form.disabled', defaultMessage: 'Disabled' },
99
+ enabledHint: {
100
+ id: 'webhooks.form.enabledHint',
101
+ defaultMessage: 'Deliveries are sent as content changes.'
102
+ },
103
+ disabledHint: {
104
+ id: 'webhooks.form.disabledHint',
105
+ defaultMessage: 'Nothing is sent while this is off.'
106
+ },
107
+ disableTitle: {
108
+ id: 'webhooks.form.disableTitle',
109
+ defaultMessage: 'Turn this endpoint off?'
110
+ },
111
+ disableBody: {
112
+ id: 'webhooks.form.disableBody',
113
+ defaultMessage: 'Events that happen while it is off are not queued for later — there is nothing to catch up on when you turn it back on. Deliveries already waiting are closed as failed.'
114
+ },
115
+ disableConfirm: {
116
+ id: 'webhooks.form.disableConfirm',
117
+ defaultMessage: 'Turn it off'
118
+ },
119
+ cancelDisable: {
120
+ id: 'webhooks.form.cancelDisable',
121
+ defaultMessage: 'Keep it on'
122
+ },
123
+ headers: {
124
+ id: 'webhooks.form.headers',
125
+ defaultMessage: 'Custom headers'
126
+ },
127
+ headersHint: {
128
+ id: 'webhooks.form.headersHint',
129
+ defaultMessage: 'Sent with every delivery — an Authorization header, say, if the receiver wants one of its own on top of the signature.'
130
+ },
131
+ headerName: {
132
+ id: 'webhooks.form.headerName',
133
+ defaultMessage: 'Header name'
134
+ },
135
+ headerValue: {
136
+ id: 'webhooks.form.headerValue',
137
+ defaultMessage: 'Header value'
138
+ },
139
+ addHeader: { id: 'webhooks.form.addHeader', defaultMessage: 'Add header' },
140
+ removeHeader: {
141
+ id: 'webhooks.form.removeHeader',
142
+ defaultMessage: 'Remove {name}'
143
+ },
144
+ headerReserved: {
145
+ id: 'webhooks.form.headerReserved',
146
+ defaultMessage: '“{name}” is reserved for the delivery’s own metadata and can’t be set here.'
147
+ },
148
+ headerMalformed: {
149
+ id: 'webhooks.form.headerMalformed',
150
+ defaultMessage: '“{name}” is not a valid header name.'
151
+ },
152
+ save: { id: 'webhooks.form.save', defaultMessage: 'Save' },
153
+ create: { id: 'webhooks.form.create', defaultMessage: 'Create webhook' },
154
+ cancel: { id: 'webhooks.form.cancel', defaultMessage: 'Cancel' },
155
+ nameRequired: {
156
+ id: 'webhooks.form.nameRequired',
157
+ defaultMessage: 'Give the webhook a name.'
158
+ },
159
+ urlRequired: {
160
+ id: 'webhooks.form.urlRequired',
161
+ defaultMessage: 'Enter the URL deliveries should be posted to.'
162
+ },
163
+ noWorkspaces: {
164
+ id: 'webhooks.form.noWorkspaces',
165
+ defaultMessage: 'Choose at least one workspace, or switch on “All workspaces”. As it stands this endpoint would receive nothing.'
166
+ }
167
+ });
168
+ /** The values a fresh dialog opens with. */
169
+ function emptyValues() {
170
+ return {
171
+ name: '',
172
+ url: '',
173
+ enabled: true,
174
+ // Defaults to false: reaching across every workspace should be
175
+ // something someone chose, not something they forgot to narrow.
176
+ allWorkspaces: false,
177
+ workspaceIds: [],
178
+ // Defaults to true, for the opposite reason: an endpoint that takes no
179
+ // events is not a safer endpoint, it is a broken one.
180
+ allEvents: true,
181
+ eventKinds: [],
182
+ // Same reasoning as events: a subscription narrowed to nothing is not
183
+ // a safer one, so the default is the whole registry.
184
+ allContentTypes: true,
185
+ contentTypes: [],
186
+ headers: []
187
+ };
188
+ }
189
+ /** An existing endpoint, as the form edits it. */
190
+ function valuesOf(endpoint) {
191
+ return {
192
+ name: endpoint.name,
193
+ url: endpoint.url,
194
+ enabled: endpoint.enabled,
195
+ allWorkspaces: endpoint.allWorkspaces,
196
+ workspaceIds: endpoint.workspaceIds,
197
+ allEvents: endpoint.eventKinds.length === 0,
198
+ eventKinds: endpoint.eventKinds,
199
+ allContentTypes: endpoint.contentTypes.length === 0,
200
+ contentTypes: endpoint.contentTypes,
201
+ headers: toHeaderRows(endpoint.headers)
202
+ };
203
+ }
204
+ /**
205
+ * Create and edit an endpoint.
206
+ *
207
+ * The three filters are each an "All…" toggle over a picker, and the toggle is
208
+ * not cosmetic: it is the difference between "everything, including what does
209
+ * not exist yet" and an enumerated list that silently stops covering the next
210
+ * workspace or event kind. Turning one on sends **no** value for that filter,
211
+ * which is exactly how the server reads "all".
212
+ */
213
+ export function WebhookForm({ endpoint, events, workspaces, contentTypes, pending, error, onSubmit, onCancel }) {
214
+ const intl = useIntl();
215
+ const [values, setValues] = useState(emptyValues);
216
+ const [touched, setTouched] = useState(false);
217
+ // Switching an endpoint off is not undoable in the way a form field is:
218
+ // what happens while it is off is simply not recorded, so the switch asks.
219
+ const [confirmDisable, setConfirmDisable] = useState(false);
220
+ // Rows need a key that survives editing the name — using the name itself
221
+ // would remount the input on every keystroke and lose the caret.
222
+ const nextRowKey = useRef(0);
223
+ // Seed once the endpoint arrives — the page mounts before its query
224
+ // resolves. Keyed on the id rather than the object, so a background refetch
225
+ // cannot discard edits in progress by re-seeding from the server's copy.
226
+ // Depending on the id rather than the object is deliberate: a background
227
+ // refetch hands back a new object with the same contents, and re-seeding on
228
+ // that would discard whatever is half-typed. `useRef` holds the endpoint so
229
+ // the effect can read it without listing it.
230
+ const latest = useRef(endpoint);
231
+ latest.current = endpoint;
232
+ const endpointId = endpoint?.id;
233
+ useEffect(() => {
234
+ const seed = latest.current;
235
+ if (!seed)
236
+ return;
237
+ setValues(valuesOf(seed));
238
+ setTouched(false);
239
+ }, [endpointId]);
240
+ const eventOptions = useMemo(() => events
241
+ // `ping` is what the Send-test button produces; it is not
242
+ // something an endpoint subscribes to.
243
+ .filter((event) => event.kind !== 'ping')
244
+ .map((event) => ({ value: event.kind, label: event.label })), [events]);
245
+ const workspaceOptions = useMemo(() => workspaces.map((workspace) => ({
246
+ value: workspace.id,
247
+ label: workspace.name
248
+ })), [workspaces]);
249
+ // The registry's types plus any selected name it does not know. The unknown
250
+ // ones are labelled as such rather than hidden: an endpoint subscribed to a
251
+ // type that has been removed, or to one with a typo in its name, is
252
+ // receiving nothing and this is the only place that shows.
253
+ // Narrowed to what the chosen workspaces were actually granted: a delivery
254
+ // needs the event's workspace *and* its type to match, so a type none of
255
+ // them can hold would never fire here.
256
+ const offeredContentTypes = useMemo(() => grantedContentTypes(contentTypes, workspaces, {
257
+ allWorkspaces: values.allWorkspaces,
258
+ workspaceIds: values.workspaceIds
259
+ }), [contentTypes, workspaces, values.allWorkspaces, values.workspaceIds]);
260
+ const contentTypeOptions = useMemo(() => contentTypeChoices(offeredContentTypes, values.contentTypes).map((choice) => ({
261
+ value: choice.value,
262
+ label: choice.known
263
+ ? choice.label
264
+ : intl.formatMessage(messages.unknownContentType, {
265
+ name: choice.value
266
+ })
267
+ })), [offeredContentTypes, values.contentTypes, intl]);
268
+ // Which types are on offer is a question about the workspaces, so it cannot
269
+ // be asked before they are chosen. An endpoint that already carries a type
270
+ // filter is the exception — hiding it would leave a saved subscription
271
+ // invisible and uneditable.
272
+ const workspaceChosen = values.allWorkspaces || values.workspaceIds.length > 0;
273
+ const contentTypesAnswerable = workspaceChosen || values.contentTypes.length > 0;
274
+ // Chosen workspaces that were granted nothing: the picker is empty for a
275
+ // reason worth stating, rather than looking broken.
276
+ const noGrants = workspaceChosen &&
277
+ !values.allWorkspaces &&
278
+ offeredContentTypes.length === 0;
279
+ const addTypedContentTypes = (raw) => {
280
+ const names = parseContentTypeNames(raw);
281
+ if (names.length === 0)
282
+ return;
283
+ setValues((prev) => ({
284
+ ...prev,
285
+ contentTypes: addContentTypeNames(prev.contentTypes, names)
286
+ }));
287
+ };
288
+ const setHeaders = (next) => setValues((prev) => ({ ...prev, headers: next }));
289
+ // Named rows only: an untouched empty row is someone about to type.
290
+ const headerErrors = values.headers.map((row) => {
291
+ if (row.name.trim().length === 0)
292
+ return null;
293
+ const rejection = rejectionFor(row.name);
294
+ if (!rejection)
295
+ return null;
296
+ return intl.formatMessage(rejection === 'reserved'
297
+ ? messages.headerReserved
298
+ : messages.headerMalformed, { name: row.name.trim() });
299
+ });
300
+ const headersValid = headerErrors.every((error) => error === null);
301
+ const nameError = touched && values.name.trim().length === 0
302
+ ? intl.formatMessage(messages.nameRequired)
303
+ : undefined;
304
+ const urlError = touched && values.url.trim().length === 0
305
+ ? intl.formatMessage(messages.urlRequired)
306
+ : undefined;
307
+ // Not a hard error — the server accepts it — but an endpoint scoped to no
308
+ // workspace receives nothing, which is never what someone meant.
309
+ const emptyWorkspaces = !values.allWorkspaces && values.workspaceIds.length === 0;
310
+ const submit = () => {
311
+ setTouched(true);
312
+ if (values.name.trim().length === 0 ||
313
+ values.url.trim().length === 0 ||
314
+ // The server refuses these too; stopping here keeps the message
315
+ // beside the row that caused it.
316
+ !headersValid) {
317
+ return;
318
+ }
319
+ onSubmit({
320
+ name: values.name.trim(),
321
+ url: values.url.trim(),
322
+ enabled: values.enabled,
323
+ allWorkspaces: values.allWorkspaces,
324
+ workspaceIds: values.allWorkspaces ? [] : values.workspaceIds,
325
+ // An empty list is how "all" is spelled on the wire, for both.
326
+ eventKinds: values.allEvents ? [] : values.eventKinds,
327
+ contentTypes: values.allContentTypes ? [] : values.contentTypes,
328
+ headers: fromHeaderRows(values.headers)
329
+ });
330
+ };
331
+ return (
332
+ // Two columns on a wide screen, one on a narrow one. The split is the
333
+ // two questions an endpoint answers — where a delivery goes, and what
334
+ // is worth sending — which is also the order they are decided in.
335
+ _jsxs("div", { className: "grid max-w-5xl gap-x-12 gap-y-8 lg:grid-cols-2", children: [_jsxs("section", { className: "flex flex-col gap-4", children: [_jsx("h2", { className: "text-sm font-medium text-muted-foreground", children: intl.formatMessage(messages.target) }), _jsx(InputField, { id: "webhook-name", label: intl.formatMessage(messages.name), value: values.name, placeholder: intl.formatMessage(messages.namePlaceholder), error: nameError, onChange: (event) => setValues((prev) => ({
336
+ ...prev,
337
+ name: event.target.value
338
+ })) }), _jsx(InputField, { id: "webhook-url", label: intl.formatMessage(messages.url), value: values.url, type: "url", placeholder: "https://example.com/hooks/ortha", description: intl.formatMessage(messages.urlHint), error: urlError, onChange: (event) => setValues((prev) => ({
339
+ ...prev,
340
+ url: event.target.value
341
+ })) }), _jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "text-sm font-medium", children: intl.formatMessage(messages.status) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Switch, { id: "webhook-enabled", checked: values.enabled, onCheckedChange: (checked) => {
342
+ // Turning it on is harmless and immediate;
343
+ // turning it off loses whatever happens next,
344
+ // so only that direction asks.
345
+ if (checked) {
346
+ setValues((prev) => ({
347
+ ...prev,
348
+ enabled: true
349
+ }));
350
+ return;
351
+ }
352
+ setConfirmDisable(true);
353
+ } }), _jsx(Label, { htmlFor: "webhook-enabled", className: "text-sm font-normal", children: intl.formatMessage(values.enabled
354
+ ? messages.enabled
355
+ : messages.disabled) })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: intl.formatMessage(values.enabled
356
+ ? messages.enabledHint
357
+ : messages.disabledHint) })] }), _jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "text-sm font-medium", children: intl.formatMessage(messages.headers) }), values.headers.map((row, index) => (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Label, { htmlFor: `webhook-header-name-${row.key}`, className: "sr-only", children: intl.formatMessage(messages.headerName) }), _jsx(Input, { id: `webhook-header-name-${row.key}`, value: row.name, placeholder: "Authorization", className: "font-mono text-sm", "aria-invalid": headerErrors[index] !== null
358
+ ? true
359
+ : undefined, onChange: (event) => setHeaders(values.headers.map((candidate) => candidate.key === row.key
360
+ ? {
361
+ ...candidate,
362
+ name: event.target
363
+ .value
364
+ }
365
+ : candidate)) }), _jsx(Label, { htmlFor: `webhook-header-value-${row.key}`, className: "sr-only", children: intl.formatMessage(messages.headerValue) }), _jsx(Input, { id: `webhook-header-value-${row.key}`, value: row.value, placeholder: "Bearer \u2026", className: "font-mono text-sm", onChange: (event) => setHeaders(values.headers.map((candidate) => candidate.key === row.key
366
+ ? {
367
+ ...candidate,
368
+ value: event.target
369
+ .value
370
+ }
371
+ : candidate)) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": intl.formatMessage(messages.removeHeader, {
372
+ name: row.name.trim() ||
373
+ intl.formatMessage(messages.headerName)
374
+ }), onClick: () => setHeaders(values.headers.filter((candidate) => candidate.key !== row.key)), children: _jsx(X, { "aria-hidden": true }) })] }), headerErrors[index] ? (_jsx("p", { role: "alert", className: "text-xs text-destructive", children: headerErrors[index] })) : null] }, row.key))), _jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setHeaders([
375
+ ...values.headers,
376
+ {
377
+ key: `row-${nextRowKey.current++}`,
378
+ name: '',
379
+ value: ''
380
+ }
381
+ ]), children: [_jsx(Plus, {}), intl.formatMessage(messages.addHeader)] }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: intl.formatMessage(messages.headersHint) })] })] }), _jsxs("section", { className: "flex flex-col gap-4", children: [_jsx("h2", { className: "text-sm font-medium text-muted-foreground", children: intl.formatMessage(messages.subscription) }), _jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "text-sm font-medium", children: intl.formatMessage(messages.workspaces) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "webhook-all-workspaces", checked: values.allWorkspaces, onCheckedChange: (checked) => setValues((prev) => ({
382
+ ...prev,
383
+ allWorkspaces: checked === true
384
+ })) }), _jsx(Label, { htmlFor: "webhook-all-workspaces", className: "text-sm font-normal", children: intl.formatMessage(messages.allWorkspaces) })] }), !values.allWorkspaces ? (_jsxs(_Fragment, { children: [_jsx(Label, { htmlFor: "webhook-workspaces", className: "sr-only", children: intl.formatMessage(messages.workspaces) }), _jsx(MultiSelect, { id: "webhook-workspaces", options: workspaceOptions, value: values.workspaceIds, onChange: (next) => setValues((prev) => ({
385
+ ...prev,
386
+ workspaceIds: next
387
+ })), placeholder: intl.formatMessage(messages.workspacesPlaceholder) })] })) : null, _jsx("p", { className: "text-xs text-muted-foreground", children: intl.formatMessage(messages.workspacesHint) }), emptyWorkspaces ? (_jsx("p", { className: "text-xs text-destructive", children: intl.formatMessage(messages.noWorkspaces) })) : null] }), _jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "text-sm font-medium", children: intl.formatMessage(messages.events) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "webhook-all-events", checked: values.allEvents, onCheckedChange: (checked) => setValues((prev) => ({
388
+ ...prev,
389
+ allEvents: checked === true
390
+ })) }), _jsx(Label, { htmlFor: "webhook-all-events", className: "text-sm font-normal", children: intl.formatMessage(messages.allEvents) })] }), !values.allEvents ? (_jsxs(_Fragment, { children: [_jsx(Label, { htmlFor: "webhook-events", className: "sr-only", children: intl.formatMessage(messages.events) }), _jsx(MultiSelect, { id: "webhook-events", options: eventOptions, value: values.eventKinds, onChange: (next) => setValues((prev) => ({
391
+ ...prev,
392
+ eventKinds: next
393
+ })), placeholder: intl.formatMessage(messages.eventsPlaceholder) })] })) : null] }), _jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "text-sm font-medium", children: intl.formatMessage(messages.contentTypes) }), !contentTypesAnswerable ? (_jsx("p", { className: "text-xs text-muted-foreground", children: intl.formatMessage(messages.contentTypesNeedWorkspace) })) : null, contentTypesAnswerable ? (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "webhook-all-content-types", checked: values.allContentTypes, onCheckedChange: (checked) => setValues((prev) => ({
394
+ ...prev,
395
+ allContentTypes: checked === true
396
+ })) }), _jsx(Label, { htmlFor: "webhook-all-content-types", className: "text-sm font-normal", children: intl.formatMessage(messages.allContentTypes) })] })) : null, contentTypesAnswerable && !values.allContentTypes ? (_jsxs(_Fragment, { children: [_jsx(Label, { htmlFor: "webhook-content-types", className: "sr-only", children: intl.formatMessage(messages.contentTypes) }), _jsx(MultiSelect, { id: "webhook-content-types", options: contentTypeOptions, value: values.contentTypes, onChange: (next) => setValues((prev) => ({
397
+ ...prev,
398
+ contentTypes: next
399
+ })), placeholder: intl.formatMessage(messages.contentTypesPlaceholder), searchPlaceholder: intl.formatMessage(messages.contentTypeSearch),
400
+ // The search box doubles as the way in for
401
+ // a name the registry has no row for — one
402
+ // control instead of a picker beside a
403
+ // second field that meant the same thing.
404
+ onCreate: addTypedContentTypes, createLabel: (name) => intl.formatMessage(messages.addContentType, { name }) }), noGrants ? (_jsx("p", { className: "text-xs text-muted-foreground", children: intl.formatMessage(messages.contentTypesNoneGranted) })) : null, !noGrants && values.contentTypes.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: intl.formatMessage(messages.noContentTypes) })) : null] })) : null, contentTypesAnswerable && !noGrants ? (_jsxs("p", { className: "text-xs text-muted-foreground", children: [intl.formatMessage(messages.contentTypesHint), !values.allContentTypes &&
405
+ !values.allWorkspaces &&
406
+ values.workspaceIds.length > 0
407
+ ? ` ${intl.formatMessage(messages.contentTypesScoped)}`
408
+ : ''] })) : null] })] }), _jsxs("div", { className: "flex flex-col gap-4 lg:col-span-2", children: [error ? (_jsx(Alert, { variant: "destructive", role: "alert", children: _jsx(AlertDescription, { children: error }) })) : null, _jsxs("div", { className: "flex items-center gap-2 border-t pt-4", children: [_jsx(Button, { onClick: submit, disabled: pending, children: intl.formatMessage(endpoint ? messages.save : messages.create) }), _jsx(Button, { variant: "ghost", onClick: onCancel, children: intl.formatMessage(messages.cancel) })] })] }), _jsx(ConfirmDialog, { open: confirmDisable, onOpenChange: setConfirmDisable, title: intl.formatMessage(messages.disableTitle), description: intl.formatMessage(messages.disableBody), confirmLabel: intl.formatMessage(messages.disableConfirm), cancelLabel: intl.formatMessage(messages.cancelDisable), confirmVariant: "destructive", onConfirm: () => {
409
+ setValues((prev) => ({ ...prev, enabled: false }));
410
+ setConfirmDisable(false);
411
+ } })] }));
412
+ }
@@ -0,0 +1,5 @@
1
+ /** The empty state: nothing is configured yet. Offers the create action when allowed. */
2
+ export declare function WebhooksEmpty({ onCreate }: {
3
+ onCreate?: () => void;
4
+ }): import("react").JSX.Element;
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/lib/presentation/components/WebhooksEmpty/index.tsx"],"names":[],"mappings":"AAoBA,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,+BAmBpE"}
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { defineMessages, useIntl } from 'react-intl';
3
+ import { Plus, Webhook } from 'lucide-react';
4
+ import { Button } from '@orthacms/design-system';
5
+ const messages = defineMessages({
6
+ title: {
7
+ id: 'webhooks.empty.title',
8
+ defaultMessage: 'No webhooks yet'
9
+ },
10
+ description: {
11
+ id: 'webhooks.empty.description',
12
+ defaultMessage: 'Add an endpoint to have this CMS notify another system when content changes — a site rebuild, a cache purge, a search re-index.'
13
+ },
14
+ create: {
15
+ id: 'webhooks.empty.create',
16
+ defaultMessage: 'New webhook'
17
+ }
18
+ });
19
+ /** The empty state: nothing is configured yet. Offers the create action when allowed. */
20
+ export function WebhooksEmpty({ onCreate }) {
21
+ const intl = useIntl();
22
+ return (_jsxs("div", { className: "mt-8 flex flex-col items-center gap-3 text-center", children: [_jsx(Webhook, { className: "size-10 text-muted-foreground", "aria-hidden": true }), _jsx("h2", { className: "text-lg font-medium", children: intl.formatMessage(messages.title) }), _jsx("p", { className: "max-w-md text-sm text-muted-foreground", children: intl.formatMessage(messages.description) }), onCreate ? (_jsxs(Button, { className: "mt-2", onClick: onCreate, children: [_jsx(Plus, {}), intl.formatMessage(messages.create)] })) : null] }));
23
+ }
@@ -0,0 +1,3 @@
1
+ /** Shown when the signed-in user lacks `webhooks:read`. */
2
+ export declare function WebhooksNoAccess(): import("react").JSX.Element;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/lib/presentation/components/WebhooksNoAccess/index.tsx"],"names":[],"mappings":"AAeA,2DAA2D;AAC3D,wBAAgB,gBAAgB,gCAgB/B"}
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { defineMessages, useIntl } from 'react-intl';
3
+ import { ShieldAlert } from 'lucide-react';
4
+ const messages = defineMessages({
5
+ title: {
6
+ id: 'webhooks.noAccess.title',
7
+ defaultMessage: 'You don’t have access to webhooks'
8
+ },
9
+ description: {
10
+ id: 'webhooks.noAccess.description',
11
+ defaultMessage: 'Ask an administrator for the “webhooks:read” permission. Webhooks reach across every workspace and hold a signing secret, so both viewing and managing them are administrator-only.'
12
+ }
13
+ });
14
+ /** Shown when the signed-in user lacks `webhooks:read`. */
15
+ export function WebhooksNoAccess() {
16
+ const intl = useIntl();
17
+ return (_jsxs("div", { className: "mt-8 flex flex-col items-center gap-3 text-center", children: [_jsx(ShieldAlert, { className: "size-10 text-muted-foreground", "aria-hidden": true }), _jsx("h2", { className: "text-lg font-medium", children: intl.formatMessage(messages.title) }), _jsx("p", { className: "max-w-md text-sm text-muted-foreground", children: intl.formatMessage(messages.description) })] }));
18
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The table placeholder shown while the list loads. Built from the real `Table`
3
+ * primitives inside the same card frame the loaded table uses, so the columns
4
+ * line up and nothing reflows on swap. Owns the single `role="status"`
5
+ * announcement; the table itself is `aria-hidden`.
6
+ */
7
+ export declare function WebhooksSkeleton({ rows }: {
8
+ rows?: number;
9
+ }): import("react").JSX.Element;
10
+ /**
11
+ * Full-page placeholder for the lazy route's `Suspense` fallback.
12
+ *
13
+ * The chrome — the top bar and the container header — is **real**, not
14
+ * skeleton: it is the page's identity, so it must be there from the first paint
15
+ * rather than popping in when the chunk resolves. Only the table is a skeleton,
16
+ * which is exactly what the page then swaps for its own pending state, so the
17
+ * chunk boundary is invisible.
18
+ */
19
+ export declare function WebhooksPageSkeleton(): import("react").JSX.Element;
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/lib/presentation/components/WebhooksSkeleton/index.tsx"],"names":[],"mappings":"AA4BA;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,IAAQ,EAAE,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,+BAyD/D;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,gCAuBnC"}
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { defineMessages, useIntl } from 'react-intl';
3
+ import { Webhook } from 'lucide-react';
4
+ import { PageTopBar } from '@orthacms/shell-admin';
5
+ import { Container, ContainerHeader, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@orthacms/design-system';
6
+ /** Intl descriptors for the webhook skeletons, co-located here. */
7
+ const messages = defineMessages({
8
+ title: { id: 'webhooks.page.title', defaultMessage: 'Webhooks' },
9
+ subtitle: {
10
+ id: 'webhooks.page.subtitle',
11
+ defaultMessage: 'Endpoints this CMS notifies when content changes.'
12
+ },
13
+ loading: {
14
+ id: 'webhooks.skeleton.loading',
15
+ defaultMessage: 'Loading webhooks…'
16
+ }
17
+ });
18
+ /**
19
+ * The table placeholder shown while the list loads. Built from the real `Table`
20
+ * primitives inside the same card frame the loaded table uses, so the columns
21
+ * line up and nothing reflows on swap. Owns the single `role="status"`
22
+ * announcement; the table itself is `aria-hidden`.
23
+ */
24
+ export function WebhooksSkeleton({ rows = 4 }) {
25
+ const intl = useIntl();
26
+ return (_jsxs("div", { role: "status", children: [_jsx("span", { className: "sr-only", children: intl.formatMessage(messages.loading) }), _jsx("div", { "aria-hidden": true, className: "mt-4 overflow-hidden rounded-xl border bg-card shadow-xs", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: _jsx(Skeleton, { className: "h-4 w-24" }) }), _jsx(TableHead, { children: _jsx(Skeleton, { className: "h-4 w-40" }) }), _jsx(TableHead, { children: _jsx(Skeleton, { className: "h-4 w-28" }) }), _jsx(TableHead, { children: _jsx(Skeleton, { className: "h-4 w-20" }) }), _jsx(TableHead, { children: _jsx(Skeleton, { className: "h-4 w-24" }) })] }) }), _jsx(TableBody, { children: Array.from({ length: rows }).map((_, index) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-32" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-56" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-5 w-24 rounded-full" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-5 w-16 rounded-full" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-24" }) })] }, index))) })] }) })] }));
27
+ }
28
+ /**
29
+ * Full-page placeholder for the lazy route's `Suspense` fallback.
30
+ *
31
+ * The chrome — the top bar and the container header — is **real**, not
32
+ * skeleton: it is the page's identity, so it must be there from the first paint
33
+ * rather than popping in when the chunk resolves. Only the table is a skeleton,
34
+ * which is exactly what the page then swaps for its own pending state, so the
35
+ * chunk boundary is invisible.
36
+ */
37
+ export function WebhooksPageSkeleton() {
38
+ const intl = useIntl();
39
+ return (_jsxs(_Fragment, { children: [_jsx(PageTopBar, { icon: Webhook, crumbs: [
40
+ {
41
+ key: 'webhooks',
42
+ label: intl.formatMessage(messages.title)
43
+ }
44
+ ] }), _jsxs(Container, { children: [_jsx(ContainerHeader, { title: intl.formatMessage(messages.title), subtitle: intl.formatMessage(messages.subtitle) }), _jsx(WebhooksSkeleton, {})] })] }));
45
+ }
@@ -0,0 +1,12 @@
1
+ import type { WebhookEndpoint } from '../../../domain/types/webhook';
2
+ /**
3
+ * The endpoint list.
4
+ *
5
+ * The filter columns read as chips rather than as lists, because the question
6
+ * from the list is "roughly what does this one take?" — the exact set belongs on
7
+ * the detail page, where it can be edited.
8
+ */
9
+ export declare function WebhooksTable({ endpoints }: {
10
+ endpoints: WebhookEndpoint[];
11
+ }): import("react").JSX.Element;
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/lib/presentation/components/WebhooksTable/index.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AA2CrE;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,EAAE,eAAe,EAAE,CAAA;CAAE,+BAwI5E"}