@syndew/oluso-admin-ui 0.0.0-stage → 1.30.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 (57) hide show
  1. package/LICENSE +76 -0
  2. package/README.md +29 -2
  3. package/fesm2022/syndew-oluso-admin-ui-entitlements-page.component-BeZtIUnk.mjs +149 -0
  4. package/fesm2022/syndew-oluso-admin-ui-entitlements-page.component-BeZtIUnk.mjs.map +1 -0
  5. package/fesm2022/syndew-oluso-admin-ui-license-status-page.component-JQmj_qhz.mjs +205 -0
  6. package/fesm2022/syndew-oluso-admin-ui-license-status-page.component-JQmj_qhz.mjs.map +1 -0
  7. package/fesm2022/syndew-oluso-admin-ui-platform-clients-page.component-ab3WDRsI.mjs +80 -0
  8. package/fesm2022/syndew-oluso-admin-ui-platform-clients-page.component-ab3WDRsI.mjs.map +1 -0
  9. package/fesm2022/syndew-oluso-admin-ui-search-results-page.component-EH82yuW3.mjs +543 -0
  10. package/fesm2022/syndew-oluso-admin-ui-search-results-page.component-EH82yuW3.mjs.map +1 -0
  11. package/fesm2022/syndew-oluso-admin-ui-syndew-oluso-admin-ui-BVs_bAxm.mjs +22987 -0
  12. package/fesm2022/syndew-oluso-admin-ui-syndew-oluso-admin-ui-BVs_bAxm.mjs.map +1 -0
  13. package/fesm2022/syndew-oluso-admin-ui-team-detail-page.component-CjrQ89qU.mjs +566 -0
  14. package/fesm2022/syndew-oluso-admin-ui-team-detail-page.component-CjrQ89qU.mjs.map +1 -0
  15. package/fesm2022/syndew-oluso-admin-ui-unauthorized-page.component-CWYFN6mp.mjs +73 -0
  16. package/fesm2022/syndew-oluso-admin-ui-unauthorized-page.component-CWYFN6mp.mjs.map +1 -0
  17. package/fesm2022/syndew-oluso-admin-ui-webhook-detail-page.component-Bh5dZwt-.mjs +728 -0
  18. package/fesm2022/syndew-oluso-admin-ui-webhook-detail-page.component-Bh5dZwt-.mjs.map +1 -0
  19. package/fesm2022/syndew-oluso-admin-ui.mjs +3 -0
  20. package/fesm2022/syndew-oluso-admin-ui.mjs.map +1 -0
  21. package/help/en/admin.api-scopes.detail.md +43 -0
  22. package/help/en/admin.api-scopes.md +37 -0
  23. package/help/en/admin.audit-logs.md +44 -0
  24. package/help/en/admin.clients.create.md +60 -0
  25. package/help/en/admin.clients.detail.md +61 -0
  26. package/help/en/admin.clients.md +48 -0
  27. package/help/en/admin.dashboard.md +28 -0
  28. package/help/en/admin.experience.journeys.builder.md +115 -0
  29. package/help/en/admin.experience.journeys.md +54 -0
  30. package/help/en/admin.experience.plugins.md +83 -0
  31. package/help/en/admin.experience.submissions.md +71 -0
  32. package/help/en/admin.feedback.detail.md +36 -0
  33. package/help/en/admin.feedback.md +42 -0
  34. package/help/en/admin.grants.md +42 -0
  35. package/help/en/admin.identity-providers.detail.md +46 -0
  36. package/help/en/admin.identity-providers.md +39 -0
  37. package/help/en/admin.identity-resources.md +47 -0
  38. package/help/en/admin.module-seats.md +45 -0
  39. package/help/en/admin.organizations.detail.md +51 -0
  40. package/help/en/admin.organizations.md +38 -0
  41. package/help/en/admin.platform.clients.create.md +34 -0
  42. package/help/en/admin.platform.clients.detail.md +28 -0
  43. package/help/en/admin.platform.clients.md +38 -0
  44. package/help/en/admin.resources.detail.md +44 -0
  45. package/help/en/admin.resources.md +43 -0
  46. package/help/en/admin.roles.md +33 -0
  47. package/help/en/admin.settings.license.md +39 -0
  48. package/help/en/admin.settings.md +70 -0
  49. package/help/en/admin.signing-keys.md +47 -0
  50. package/help/en/admin.tenants.detail.md +58 -0
  51. package/help/en/admin.tenants.md +46 -0
  52. package/help/en/admin.users.detail.md +41 -0
  53. package/help/en/admin.users.md +56 -0
  54. package/help/en/admin.webhooks.detail.md +41 -0
  55. package/help/en/admin.webhooks.md +37 -0
  56. package/package.json +51 -3
  57. package/types/syndew-oluso-admin-ui.d.ts +5786 -0
@@ -0,0 +1,728 @@
1
+ import * as i0 from '@angular/core';
2
+ import { Injectable, inject, signal, computed, Component } from '@angular/core';
3
+ import { CommonModule } from '@angular/common';
4
+ import * as i1 from '@angular/forms';
5
+ import { FormsModule } from '@angular/forms';
6
+ import { HttpClient, HttpContext } from '@angular/common/http';
7
+ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
8
+ import { ToastService, extractErrorMessage, SKIP_ERROR_TOAST, CardComponent, ButtonComponent, BadgeComponent, ModalComponent, ConfirmDialogComponent, IconComponent, InputComponent } from '@syndew/oluso-ui-core';
9
+ import { W as WebhookAuthType, S as STATIC_WEBHOOK_AUTH_TYPES, f as ApiService, h as WebhookService, C as ClientService, D as DEFAULT_WEBHOOK_API_VERSION } from './syndew-oluso-admin-ui-syndew-oluso-admin-ui-BVs_bAxm.mjs';
10
+
11
+ /**
12
+ * Hands a newly created endpoint's signing secret to the page it navigates to, in memory only: the secret is
13
+ * shown once and must not land in the URL or the browser history.
14
+ */
15
+ class WebhookSecretHandoff {
16
+ pending = null;
17
+ put(endpointId, secret) {
18
+ this.pending = { endpointId, secret };
19
+ }
20
+ take(endpointId) {
21
+ if (this.pending?.endpointId !== endpointId)
22
+ return null;
23
+ const secret = this.pending.secret;
24
+ this.pending = null;
25
+ return secret;
26
+ }
27
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: WebhookSecretHandoff, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
28
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: WebhookSecretHandoff, providedIn: 'root' });
29
+ }
30
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: WebhookSecretHandoff, decorators: [{
31
+ type: Injectable,
32
+ args: [{ providedIn: 'root' }]
33
+ }] });
34
+ /** A header name as HTTP allows it (RFC 9110 token). */
35
+ const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
36
+ /** Headers the dispatcher sets itself; a custom header with one of these names would be overwritten or clash. */
37
+ const RESERVED_HEADERS = ['authorization', 'content-type', 'x-api-key'];
38
+ function emptyForm() {
39
+ return {
40
+ name: '',
41
+ description: '',
42
+ url: '',
43
+ enabled: true,
44
+ eventTypes: [],
45
+ authType: WebhookAuthType.None,
46
+ credential: '',
47
+ basicUsername: '',
48
+ basicPassword: '',
49
+ clientId: '',
50
+ skipOwnClientEvents: true,
51
+ authorizeSensitiveData: false,
52
+ headers: [],
53
+ };
54
+ }
55
+ /** Why the form can't be saved, per field; empty when it can. */
56
+ function validateWebhookForm(form, credentialStored) {
57
+ const errors = {};
58
+ if (!form.name.trim())
59
+ errors.name = 'Enter a name.';
60
+ const url = form.url.trim();
61
+ if (!url) {
62
+ errors.url = 'Enter the URL that receives deliveries.';
63
+ }
64
+ else {
65
+ try {
66
+ const parsed = new URL(url);
67
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
68
+ errors.url = 'Use an http or https URL.';
69
+ }
70
+ catch {
71
+ errors.url = 'Enter a full URL, for example https://api.example.com/webhooks.';
72
+ }
73
+ }
74
+ if (form.eventTypes.length === 0)
75
+ errors.eventTypes = 'Choose at least one event.';
76
+ if (STATIC_WEBHOOK_AUTH_TYPES.includes(form.authType) && !credentialStored) {
77
+ if (form.authType === WebhookAuthType.BasicAuth) {
78
+ if (!form.basicUsername.trim() || !form.basicPassword)
79
+ errors.credential = 'Enter a username and password.';
80
+ else if (form.basicUsername.includes(':'))
81
+ errors.credential = 'The username cannot contain a colon.';
82
+ }
83
+ else if (!form.credential.trim()) {
84
+ errors.credential = form.authType === WebhookAuthType.BearerToken ? 'Enter the token.' : 'Enter the API key.';
85
+ }
86
+ }
87
+ else if (form.authType === WebhookAuthType.BasicAuth && (form.basicUsername || form.basicPassword)) {
88
+ // Replacing a stored Basic credential needs both halves.
89
+ if (!form.basicUsername.trim() || !form.basicPassword)
90
+ errors.credential = 'Enter both the username and the password to replace them.';
91
+ else if (form.basicUsername.includes(':'))
92
+ errors.credential = 'The username cannot contain a colon.';
93
+ }
94
+ const seen = new Set();
95
+ for (const row of form.headers) {
96
+ const name = row.name.trim();
97
+ if (!name && !row.value)
98
+ continue;
99
+ if (!name) {
100
+ errors.headers = 'Every header needs a name.';
101
+ break;
102
+ }
103
+ if (!HEADER_NAME.test(name)) {
104
+ errors.headers = `"${name}" is not a valid header name.`;
105
+ break;
106
+ }
107
+ const lower = name.toLowerCase();
108
+ if (RESERVED_HEADERS.includes(lower) || lower.startsWith('x-webhook-')) {
109
+ errors.headers = `${name} is set by the delivery itself.`;
110
+ break;
111
+ }
112
+ if (seen.has(lower)) {
113
+ errors.headers = `${name} is listed twice.`;
114
+ break;
115
+ }
116
+ seen.add(lower);
117
+ }
118
+ return errors;
119
+ }
120
+ class WebhookDetailPageComponent {
121
+ route = inject(ActivatedRoute);
122
+ router = inject(Router);
123
+ http = inject(HttpClient);
124
+ api = inject(ApiService);
125
+ webhookService = inject(WebhookService);
126
+ clientService = inject(ClientService);
127
+ toast = inject(ToastService);
128
+ secretHandoff = inject(WebhookSecretHandoff);
129
+ AuthType = WebhookAuthType;
130
+ defaultApiVersion = DEFAULT_WEBHOOK_API_VERSION;
131
+ staticAuthOptions = [
132
+ { type: WebhookAuthType.BearerToken, label: 'Bearer token', hint: 'Sent as Authorization: Bearer <token>' },
133
+ { type: WebhookAuthType.BasicAuth, label: 'Basic auth', hint: 'Sent as Authorization: Basic <username:password>' },
134
+ { type: WebhookAuthType.ApiKeyHeader, label: 'API key header', hint: 'Sent as X-API-Key: <key>' },
135
+ ];
136
+ sections = [
137
+ { id: 'basics', label: 'Basics' },
138
+ { id: 'events', label: 'Events' },
139
+ { id: 'authentication', label: 'Authentication' },
140
+ { id: 'client', label: 'Owning client' },
141
+ { id: 'sensitive', label: 'Sensitive data' },
142
+ { id: 'advanced', label: 'Headers and version' },
143
+ ];
144
+ isNew = signal(true, ...(ngDevMode ? [{ debugName: "isNew" }] : /* istanbul ignore next */ []));
145
+ loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
146
+ saving = signal(false, ...(ngDevMode ? [{ debugName: "saving" }] : /* istanbul ignore next */ []));
147
+ submitted = signal(false, ...(ngDevMode ? [{ debugName: "submitted" }] : /* istanbul ignore next */ []));
148
+ endpoint = signal(null, ...(ngDevMode ? [{ debugName: "endpoint" }] : /* istanbul ignore next */ []));
149
+ clients = signal([], ...(ngDevMode ? [{ debugName: "clients" }] : /* istanbul ignore next */ []));
150
+ eventsGrouped = signal({}, ...(ngDevMode ? [{ debugName: "eventsGrouped" }] : /* istanbul ignore next */ []));
151
+ activeTab = signal('settings', ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
152
+ deliveries = signal([], ...(ngDevMode ? [{ debugName: "deliveries" }] : /* istanbul ignore next */ []));
153
+ deliveriesLoading = signal(false, ...(ngDevMode ? [{ debugName: "deliveriesLoading" }] : /* istanbul ignore next */ []));
154
+ deliveriesLoaded = signal(false, ...(ngDevMode ? [{ debugName: "deliveriesLoaded" }] : /* istanbul ignore next */ []));
155
+ retryingId = signal(null, ...(ngDevMode ? [{ debugName: "retryingId" }] : /* istanbul ignore next */ []));
156
+ testing = signal(false, ...(ngDevMode ? [{ debugName: "testing" }] : /* istanbul ignore next */ []));
157
+ showDeleteConfirm = signal(false, ...(ngDevMode ? [{ debugName: "showDeleteConfirm" }] : /* istanbul ignore next */ []));
158
+ deleting = signal(false, ...(ngDevMode ? [{ debugName: "deleting" }] : /* istanbul ignore next */ []));
159
+ showRotateConfirm = signal(false, ...(ngDevMode ? [{ debugName: "showRotateConfirm" }] : /* istanbul ignore next */ []));
160
+ rotating = signal(false, ...(ngDevMode ? [{ debugName: "rotating" }] : /* istanbul ignore next */ []));
161
+ showSecretModal = signal(false, ...(ngDevMode ? [{ debugName: "showSecretModal" }] : /* istanbul ignore next */ []));
162
+ newSecret = signal('', ...(ngDevMode ? [{ debugName: "newSecret" }] : /* istanbul ignore next */ []));
163
+ secretCopied = signal(false, ...(ngDevMode ? [{ debugName: "secretCopied" }] : /* istanbul ignore next */ []));
164
+ showSensitiveDataConfirm = signal(false, ...(ngDevMode ? [{ debugName: "showSensitiveDataConfirm" }] : /* istanbul ignore next */ []));
165
+ /** The tenant issuer's JWKS URL, for issuer-signed delivery. */
166
+ jwksUrl = signal(null, ...(ngDevMode ? [{ debugName: "jwksUrl" }] : /* istanbul ignore next */ []));
167
+ jwksFromDiscovery = signal(false, ...(ngDevMode ? [{ debugName: "jwksFromDiscovery" }] : /* istanbul ignore next */ []));
168
+ jwksRequested = false;
169
+ collapsedGroups = signal(new Set(), ...(ngDevMode ? [{ debugName: "collapsedGroups" }] : /* istanbul ignore next */ []));
170
+ eventSearchTerm = '';
171
+ formData = emptyForm();
172
+ eventsByType = computed(() => {
173
+ const byType = new Map();
174
+ for (const events of Object.values(this.eventsGrouped())) {
175
+ for (const event of events)
176
+ byType.set(event.eventType, event);
177
+ }
178
+ return byType;
179
+ }, ...(ngDevMode ? [{ debugName: "eventsByType" }] : /* istanbul ignore next */ []));
180
+ tabs = computed(() => this.isNew()
181
+ ? []
182
+ : [
183
+ { id: 'settings', label: 'Settings' },
184
+ { id: 'deliveries', label: 'Deliveries' },
185
+ ], ...(ngDevMode ? [{ debugName: "tabs" }] : /* istanbul ignore next */ []));
186
+ ngOnInit() {
187
+ const id = this.route.snapshot.paramMap.get('id');
188
+ this.loadEvents();
189
+ this.loadClients();
190
+ if (id && id !== 'new') {
191
+ this.isNew.set(false);
192
+ this.loadEndpoint(id, true);
193
+ }
194
+ else {
195
+ this.isNew.set(true);
196
+ this.formData = emptyForm();
197
+ this.loading.set(false);
198
+ }
199
+ }
200
+ // --- Loading ---
201
+ loadEndpoint(id, first = false) {
202
+ if (first)
203
+ this.loading.set(true);
204
+ this.webhookService.getEndpoint(id).subscribe({
205
+ next: (endpoint) => {
206
+ this.endpoint.set(endpoint);
207
+ if (first) {
208
+ this.resetForm(endpoint);
209
+ const secret = this.secretHandoff.take(endpoint.id);
210
+ if (secret) {
211
+ this.newSecret.set(secret);
212
+ this.showSecretModal.set(true);
213
+ }
214
+ }
215
+ this.loading.set(false);
216
+ },
217
+ error: (error) => {
218
+ console.error('Failed to load webhook:', error);
219
+ this.loading.set(false);
220
+ },
221
+ });
222
+ }
223
+ loadEvents() {
224
+ this.webhookService.getEventsGrouped().subscribe({
225
+ next: (grouped) => this.eventsGrouped.set(grouped),
226
+ error: (error) => console.error('Failed to load events:', error),
227
+ });
228
+ }
229
+ loadClients() {
230
+ this.clientService.getClients().subscribe({
231
+ next: (clients) => this.clients.set(clients),
232
+ error: (error) => console.error('Failed to load clients:', error),
233
+ });
234
+ }
235
+ resetForm(endpoint) {
236
+ this.formData = {
237
+ ...emptyForm(),
238
+ name: endpoint.name,
239
+ description: endpoint.description ?? '',
240
+ url: endpoint.url,
241
+ enabled: endpoint.enabled,
242
+ eventTypes: [...endpoint.eventTypes],
243
+ authType: endpoint.authType ?? WebhookAuthType.None,
244
+ clientId: endpoint.clientId ?? '',
245
+ skipOwnClientEvents: endpoint.skipOwnClientEvents,
246
+ authorizeSensitiveData: !!endpoint.sensitiveDataAuthorizedAt,
247
+ headers: Object.entries(endpoint.headers ?? {}).map(([name, value]) => ({ name, value })),
248
+ };
249
+ if (this.formData.authType === WebhookAuthType.IssuerSigned)
250
+ this.loadJwksUrl();
251
+ }
252
+ // --- Header, tabs, navigation ---
253
+ pageTitle() {
254
+ return this.isNew() ? 'New webhook' : this.endpoint()?.name || 'Webhook';
255
+ }
256
+ setActiveTab(tab) {
257
+ this.activeTab.set(tab);
258
+ if (tab === 'deliveries' && !this.deliveriesLoaded())
259
+ this.loadDeliveries();
260
+ }
261
+ scrollToSection(id) {
262
+ if (typeof document === 'undefined')
263
+ return;
264
+ document.getElementById(`webhook-${id}`)?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
265
+ }
266
+ onCancel() {
267
+ this.router.navigate(['/webhooks']);
268
+ }
269
+ // --- Validation and saving ---
270
+ /** A static credential of the selected type is already stored, so leaving the field blank keeps it. */
271
+ credentialStored() {
272
+ const endpoint = this.endpoint();
273
+ return !!endpoint && endpoint.authType === this.formData.authType && STATIC_WEBHOOK_AUTH_TYPES.includes(this.formData.authType);
274
+ }
275
+ errors() {
276
+ return validateWebhookForm(this.formData, this.credentialStored());
277
+ }
278
+ /** The error to show for a field, once the admin has tried to save. */
279
+ fieldError(field) {
280
+ return this.submitted() ? this.errors()[field] : undefined;
281
+ }
282
+ onSave() {
283
+ this.submitted.set(true);
284
+ const errors = this.errors();
285
+ const firstInvalid = Object.keys(errors)[0];
286
+ if (firstInvalid) {
287
+ this.activeTab.set('settings');
288
+ this.toast.error('Check the highlighted fields.');
289
+ const section = { name: 'basics', url: 'basics', eventTypes: 'events', credential: 'authentication', headers: 'advanced' }[firstInvalid];
290
+ setTimeout(() => this.scrollToSection(section));
291
+ return;
292
+ }
293
+ const endpoint = this.endpoint();
294
+ if (this.isNew() || !endpoint)
295
+ this.create();
296
+ else
297
+ this.update(endpoint);
298
+ }
299
+ headersMap() {
300
+ const map = {};
301
+ for (const row of this.formData.headers) {
302
+ if (row.name.trim())
303
+ map[row.name.trim()] = row.value;
304
+ }
305
+ return map;
306
+ }
307
+ /** The credential the form holds for the selected static type, or undefined to keep (or not set) one. */
308
+ credentialValue() {
309
+ switch (this.formData.authType) {
310
+ case WebhookAuthType.BearerToken:
311
+ case WebhookAuthType.ApiKeyHeader:
312
+ return this.formData.credential.trim() || undefined;
313
+ case WebhookAuthType.BasicAuth:
314
+ return this.formData.basicUsername.trim() && this.formData.basicPassword
315
+ ? `${this.formData.basicUsername.trim()}:${this.formData.basicPassword}`
316
+ : undefined;
317
+ default:
318
+ return undefined;
319
+ }
320
+ }
321
+ buildCreateRequest() {
322
+ const headers = this.headersMap();
323
+ return {
324
+ name: this.formData.name.trim(),
325
+ description: this.formData.description.trim() || undefined,
326
+ url: this.formData.url.trim(),
327
+ enabled: this.formData.enabled,
328
+ eventTypes: this.formData.eventTypes,
329
+ headers: Object.keys(headers).length ? headers : undefined,
330
+ authType: this.formData.authType,
331
+ authCredential: this.credentialValue(),
332
+ apiVersion: DEFAULT_WEBHOOK_API_VERSION,
333
+ clientId: this.formData.clientId || undefined,
334
+ skipOwnClientEvents: this.formData.skipOwnClientEvents,
335
+ authorizeSensitiveData: this.formData.authorizeSensitiveData,
336
+ };
337
+ }
338
+ buildUpdateRequest(endpoint) {
339
+ const request = {
340
+ name: this.formData.name.trim(),
341
+ description: this.formData.description.trim(),
342
+ url: this.formData.url.trim(),
343
+ enabled: this.formData.enabled,
344
+ eventTypes: this.formData.eventTypes,
345
+ headers: this.headersMap(),
346
+ clientId: this.formData.clientId,
347
+ skipOwnClientEvents: this.formData.skipOwnClientEvents,
348
+ authorizeSensitiveData: this.formData.authorizeSensitiveData,
349
+ };
350
+ // The type is sent only when it changes, so an endpoint whose type the server didn't report keeps it.
351
+ if (this.formData.authType !== (endpoint.authType ?? WebhookAuthType.None)) {
352
+ request.authType = this.formData.authType;
353
+ }
354
+ const credential = this.credentialValue();
355
+ if (credential !== undefined)
356
+ request.authCredential = credential;
357
+ return request;
358
+ }
359
+ create() {
360
+ this.saving.set(true);
361
+ this.webhookService.createEndpoint(this.buildCreateRequest()).subscribe({
362
+ next: (created) => {
363
+ this.saving.set(false);
364
+ if (created.secret && this.formData.authType !== WebhookAuthType.IssuerSigned) {
365
+ this.secretHandoff.put(created.id, created.secret);
366
+ }
367
+ this.toast.success('Webhook created');
368
+ this.router.navigate(['/webhooks', created.id]);
369
+ },
370
+ error: (error) => {
371
+ this.toast.error(extractErrorMessage(error, 'Failed to create webhook'));
372
+ this.saving.set(false);
373
+ },
374
+ });
375
+ }
376
+ update(endpoint) {
377
+ this.saving.set(true);
378
+ this.webhookService.updateEndpoint(endpoint.id, this.buildUpdateRequest(endpoint)).subscribe({
379
+ next: (updated) => {
380
+ this.saving.set(false);
381
+ this.submitted.set(false);
382
+ // Keep stats and anything the update response leaves out.
383
+ const merged = { ...endpoint, ...updated, authType: updated?.authType ?? this.formData.authType };
384
+ this.endpoint.set(merged);
385
+ this.resetForm(merged);
386
+ this.toast.success('Changes saved');
387
+ },
388
+ error: (error) => {
389
+ this.toast.error(extractErrorMessage(error, 'Failed to update webhook'));
390
+ this.saving.set(false);
391
+ },
392
+ });
393
+ }
394
+ // --- Delete ---
395
+ confirmDelete() {
396
+ const endpoint = this.endpoint();
397
+ if (!endpoint)
398
+ return;
399
+ this.deleting.set(true);
400
+ this.webhookService.deleteEndpoint(endpoint.id).subscribe({
401
+ next: () => {
402
+ this.deleting.set(false);
403
+ this.showDeleteConfirm.set(false);
404
+ this.toast.success('Webhook deleted');
405
+ this.router.navigate(['/webhooks']);
406
+ },
407
+ error: (error) => {
408
+ this.toast.error(extractErrorMessage(error, 'Failed to delete webhook'));
409
+ this.deleting.set(false);
410
+ },
411
+ });
412
+ }
413
+ // --- Authentication ---
414
+ isStaticAuth(type = this.formData.authType) {
415
+ return STATIC_WEBHOOK_AUTH_TYPES.includes(type);
416
+ }
417
+ selectAuthMode(mode) {
418
+ if (mode === 'hmac')
419
+ this.formData.authType = WebhookAuthType.None;
420
+ else if (mode === 'issuer') {
421
+ this.formData.authType = WebhookAuthType.IssuerSigned;
422
+ this.loadJwksUrl();
423
+ }
424
+ else if (!this.isStaticAuth()) {
425
+ const stored = this.endpoint()?.authType;
426
+ this.formData.authType = stored !== undefined && this.isStaticAuth(stored) ? stored : WebhookAuthType.BearerToken;
427
+ }
428
+ }
429
+ authMode() {
430
+ if (this.formData.authType === WebhookAuthType.IssuerSigned)
431
+ return 'issuer';
432
+ return this.isStaticAuth() ? 'static' : 'hmac';
433
+ }
434
+ /** Deliveries carry the HMAC signature, so the secret exists and can be rotated. */
435
+ usesSigningSecret() {
436
+ return this.formData.authType !== WebhookAuthType.IssuerSigned;
437
+ }
438
+ /** The endpoint was saved as issuer-signed and is being switched to a secret-based type. */
439
+ leavingIssuerSigned() {
440
+ return this.endpoint()?.authType === WebhookAuthType.IssuerSigned && this.usesSigningSecret();
441
+ }
442
+ /**
443
+ * Resolves the JWKS URL the tenant's discovery document advertises: the tenant's issuer comes from the admin
444
+ * API's server info, then jwks_uri from that issuer's discovery document. Falls back to the default path.
445
+ */
446
+ loadJwksUrl() {
447
+ if (this.jwksRequested)
448
+ return;
449
+ this.jwksRequested = true;
450
+ const quiet = { context: new HttpContext().set(SKIP_ERROR_TOAST, true) };
451
+ const fallbackFor = (issuer) => `${issuer.replace(/\/$/, '')}/.well-known/jwks`;
452
+ this.http.get(`${this.api.baseUrl}/identity-providers/server-info`, quiet).subscribe({
453
+ next: (info) => {
454
+ const issuer = (info?.issuerUri || this.api.absoluteUrl('')).replace(/\/$/, '');
455
+ this.jwksUrl.set(fallbackFor(issuer));
456
+ this.http.get(`${issuer}/.well-known/openid-configuration`, quiet).subscribe({
457
+ next: (discovery) => {
458
+ if (discovery?.jwks_uri) {
459
+ this.jwksUrl.set(discovery.jwks_uri);
460
+ this.jwksFromDiscovery.set(true);
461
+ }
462
+ },
463
+ error: () => undefined,
464
+ });
465
+ },
466
+ error: () => this.jwksUrl.set(fallbackFor(this.api.absoluteUrl(''))),
467
+ });
468
+ }
469
+ copy(value) {
470
+ if (!value)
471
+ return;
472
+ navigator.clipboard.writeText(value).then(() => this.toast.success('Copied'), () => this.toast.error('Could not copy to the clipboard'));
473
+ }
474
+ confirmRotateSecret() {
475
+ const endpoint = this.endpoint();
476
+ if (!endpoint)
477
+ return;
478
+ this.rotating.set(true);
479
+ this.webhookService.rotateSecret(endpoint.id).subscribe({
480
+ next: (response) => {
481
+ this.rotating.set(false);
482
+ this.showRotateConfirm.set(false);
483
+ this.newSecret.set(response.secret);
484
+ this.showSecretModal.set(true);
485
+ },
486
+ error: (error) => {
487
+ this.toast.error(extractErrorMessage(error, 'Failed to rotate the secret'));
488
+ this.rotating.set(false);
489
+ },
490
+ });
491
+ }
492
+ async copySecret() {
493
+ await navigator.clipboard.writeText(this.newSecret());
494
+ this.secretCopied.set(true);
495
+ setTimeout(() => this.secretCopied.set(false), 2000);
496
+ }
497
+ closeSecretModal() {
498
+ this.showSecretModal.set(false);
499
+ this.newSecret.set('');
500
+ }
501
+ // --- Owning client ---
502
+ clientLabel(clientId) {
503
+ const client = this.clients().find((c) => c.clientId === clientId);
504
+ return client?.clientName ? `${client.clientName} (${clientId})` : clientId;
505
+ }
506
+ // --- Custom headers ---
507
+ addHeader() {
508
+ this.formData.headers = [...this.formData.headers, { name: '', value: '' }];
509
+ }
510
+ removeHeader(index) {
511
+ this.formData.headers = this.formData.headers.filter((_, i) => i !== index);
512
+ }
513
+ // --- Events ---
514
+ toggleEventType(eventType) {
515
+ if (this.formData.eventTypes.includes(eventType)) {
516
+ this.formData.eventTypes = this.formData.eventTypes.filter((e) => e !== eventType);
517
+ }
518
+ else if (this.canSelect(this.eventsByType().get(eventType))) {
519
+ this.formData.eventTypes = [...this.formData.eventTypes, eventType];
520
+ }
521
+ }
522
+ isSelected(eventType) {
523
+ return this.formData.eventTypes.includes(eventType);
524
+ }
525
+ filteredGroups() {
526
+ const grouped = this.eventsGrouped();
527
+ const groups = Object.keys(grouped).map((name) => ({ name, events: grouped[name] }));
528
+ const term = this.eventSearchTerm.trim().toLowerCase();
529
+ if (!term)
530
+ return groups;
531
+ return groups
532
+ .map((group) => ({
533
+ ...group,
534
+ events: group.events.filter((e) => e.eventType.toLowerCase().includes(term) ||
535
+ e.displayName.toLowerCase().includes(term) ||
536
+ (e.description?.toLowerCase().includes(term) ?? false) ||
537
+ group.name.toLowerCase().includes(term)),
538
+ }))
539
+ .filter((group) => group.events.length > 0);
540
+ }
541
+ /** Groups start open; a search always shows its matches. */
542
+ isGroupExpanded(name) {
543
+ return !!this.eventSearchTerm.trim() || !this.collapsedGroups().has(name);
544
+ }
545
+ toggleGroup(name) {
546
+ const next = new Set(this.collapsedGroups());
547
+ if (next.has(name))
548
+ next.delete(name);
549
+ else
550
+ next.add(name);
551
+ this.collapsedGroups.set(next);
552
+ }
553
+ expandAllGroups() {
554
+ this.collapsedGroups.set(new Set());
555
+ }
556
+ collapseAllGroups() {
557
+ this.collapsedGroups.set(new Set(Object.keys(this.eventsGrouped())));
558
+ }
559
+ selectedCount(group) {
560
+ return group.events.filter((e) => this.isSelected(e.eventType)).length;
561
+ }
562
+ /** The events in the group the admin can toggle: subscribable ones, plus ones already selected. */
563
+ toggleable(group) {
564
+ return group.events.filter((e) => this.canSelect(e) || this.isSelected(e.eventType)).map((e) => e.eventType);
565
+ }
566
+ isGroupFullySelected(group) {
567
+ const types = this.toggleable(group);
568
+ return types.length > 0 && types.every((t) => this.isSelected(t));
569
+ }
570
+ isGroupPartlySelected(group) {
571
+ const count = this.selectedCount(group);
572
+ return count > 0 && !this.isGroupFullySelected(group);
573
+ }
574
+ toggleGroupSelection(group) {
575
+ const types = this.toggleable(group);
576
+ if (types.every((t) => this.isSelected(t))) {
577
+ this.formData.eventTypes = this.formData.eventTypes.filter((e) => !types.includes(e));
578
+ }
579
+ else {
580
+ this.formData.eventTypes = [...new Set([...this.formData.eventTypes, ...types])];
581
+ }
582
+ }
583
+ clearEvents() {
584
+ this.formData.eventTypes = [];
585
+ }
586
+ // --- Restricted events and sensitive data ---
587
+ /** A restricted event can only be newly subscribed by an administrator holding its permission. */
588
+ canSelect(event) {
589
+ return event?.canSubscribe !== false;
590
+ }
591
+ carriesSensitiveData(event) {
592
+ return !!event && (!!event.restricted || (event.sensitiveProperties?.length ?? 0) > 0);
593
+ }
594
+ sensitiveEventsOf(eventTypes) {
595
+ return eventTypes
596
+ .map((type) => this.eventsByType().get(type))
597
+ .filter((event) => this.carriesSensitiveData(event));
598
+ }
599
+ /** Authorising needs the permission of every restricted event the endpoint will subscribe to. */
600
+ canAuthorizeSensitiveData() {
601
+ return this.sensitiveEventsOf(this.formData.eventTypes).every((event) => this.canSelect(event));
602
+ }
603
+ restrictionTitle(event) {
604
+ const parts = [
605
+ event.classificationPurpose,
606
+ event.requiredPermission ? `Requires the ${event.requiredPermission} permission.` : undefined,
607
+ event.sensitiveProperties?.length ? `Withheld unless authorised: ${event.sensitiveProperties.join(', ')}` : undefined,
608
+ ];
609
+ return parts.filter(Boolean).join(' ');
610
+ }
611
+ authorizationTitle(endpoint) {
612
+ if (!endpoint.sensitiveDataAuthorizedAt)
613
+ return '';
614
+ const by = endpoint.sensitiveDataAuthorizedByName || endpoint.sensitiveDataAuthorizedBy;
615
+ return `Authorised by ${by} on ${this.formatDate(endpoint.sensitiveDataAuthorizedAt)}`;
616
+ }
617
+ onAuthorizeSensitiveDataChange(checked) {
618
+ this.formData.authorizeSensitiveData = false;
619
+ if (checked)
620
+ this.showSensitiveDataConfirm.set(true);
621
+ }
622
+ confirmSensitiveData() {
623
+ this.formData.authorizeSensitiveData = true;
624
+ this.showSensitiveDataConfirm.set(false);
625
+ }
626
+ cancelSensitiveData() {
627
+ this.formData.authorizeSensitiveData = false;
628
+ this.showSensitiveDataConfirm.set(false);
629
+ }
630
+ sensitiveDataConfirmMessage() {
631
+ const names = this.sensitiveEventsOf(this.formData.eventTypes).map((e) => e.displayName);
632
+ return (`Deliveries to ${this.formData.url || 'this endpoint'} will include the confidential fields of ${names.join(', ')}. ` +
633
+ 'This data leaves the platform and is then governed by the receiving system. ' +
634
+ 'Only authorise endpoints your organisation is permitted to share this data with. ' +
635
+ 'Your name and the time are recorded against this authorisation.');
636
+ }
637
+ // --- Deliveries and testing ---
638
+ loadDeliveries() {
639
+ const endpoint = this.endpoint();
640
+ if (!endpoint)
641
+ return;
642
+ this.deliveriesLoading.set(true);
643
+ this.webhookService.getDeliveries(endpoint.id).subscribe({
644
+ next: (deliveries) => {
645
+ this.deliveries.set(deliveries);
646
+ this.deliveriesLoading.set(false);
647
+ this.deliveriesLoaded.set(true);
648
+ },
649
+ error: (error) => {
650
+ console.error('Failed to load deliveries:', error);
651
+ this.deliveriesLoading.set(false);
652
+ },
653
+ });
654
+ }
655
+ testEndpoint() {
656
+ const endpoint = this.endpoint();
657
+ if (!endpoint)
658
+ return;
659
+ this.testing.set(true);
660
+ this.webhookService.testEndpoint(endpoint.id).subscribe({
661
+ next: (result) => {
662
+ this.testing.set(false);
663
+ if (result?.success === false)
664
+ this.toast.error(result.error || 'The test delivery failed');
665
+ else
666
+ this.toast.success('Test event sent');
667
+ this.loadEndpoint(endpoint.id);
668
+ if (this.deliveriesLoaded() || this.activeTab() === 'deliveries')
669
+ this.loadDeliveries();
670
+ },
671
+ error: (error) => {
672
+ this.toast.error(extractErrorMessage(error, 'Failed to send the test event'));
673
+ this.testing.set(false);
674
+ },
675
+ });
676
+ }
677
+ retryDelivery(delivery) {
678
+ this.retryingId.set(delivery.id);
679
+ this.webhookService.retryDelivery(delivery.id).subscribe({
680
+ next: () => {
681
+ this.retryingId.set(null);
682
+ this.loadDeliveries();
683
+ },
684
+ error: (error) => {
685
+ this.toast.error(extractErrorMessage(error, 'Failed to retry the delivery'));
686
+ this.retryingId.set(null);
687
+ },
688
+ });
689
+ }
690
+ canRetry(status) {
691
+ return status === 'Failed' || status === 'Exhausted';
692
+ }
693
+ getStatusVariant(status) {
694
+ switch (status) {
695
+ case 'Success':
696
+ return 'success';
697
+ case 'Failed':
698
+ return 'danger';
699
+ case 'Pending':
700
+ return 'warning';
701
+ default:
702
+ return 'default';
703
+ }
704
+ }
705
+ formatDate(dateString) {
706
+ return dateString ? new Date(dateString).toLocaleString() : 'Never';
707
+ }
708
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: WebhookDetailPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
709
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: WebhookDetailPageComponent, isStandalone: true, selector: "admin-webhook-detail-page", ngImport: i0, template: "@if (loading()) {\n <div class=\"flex justify-center py-12\" role=\"status\" aria-label=\"Loading\">\n <svg class=\"animate-spin h-8 w-8 text-primary-600\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n </div>\n} @else if (!isNew() && !endpoint()) {\n <div class=\"flex flex-col items-center justify-center py-16 text-gray-500\">\n <ui-icon name=\"link\" class=\"h-12 w-12 mb-4\" />\n <p>This webhook could not be found.</p>\n <a routerLink=\"/webhooks\" class=\"mt-2 text-sm text-primary-600 hover:text-primary-700\">Back to webhooks</a>\n </div>\n} @else {\n <div class=\"space-y-6\">\n <!-- Breadcrumb -->\n <nav aria-label=\"Breadcrumb\" class=\"text-sm\">\n <ol class=\"flex items-center gap-1 text-gray-500\">\n <li><a routerLink=\"/webhooks\" class=\"hover:text-gray-700\">Webhooks</a></li>\n <li aria-hidden=\"true\"><ui-icon name=\"chevron-right\" class=\"h-4 w-4\" /></li>\n <li class=\"text-gray-900 truncate\" aria-current=\"page\">{{ pageTitle() }}</li>\n </ol>\n </nav>\n\n <!-- Page header -->\n <div class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\">\n <div class=\"min-w-0\">\n <h1 class=\"text-2xl font-bold text-gray-900 truncate\">{{ pageTitle() }}</h1>\n @if (isNew()) {\n <p class=\"mt-1 text-sm text-gray-500\">Send events from this tenant to a URL you control.</p>\n } @else if (endpoint(); as ep) {\n <div class=\"mt-1 flex flex-wrap items-center gap-2\">\n <span class=\"font-mono text-xs text-gray-600 break-all\">{{ ep.url }}</span>\n <ui-badge [variant]=\"ep.enabled ? 'success' : 'default'\">{{ ep.enabled ? 'Active' : 'Disabled' }}</ui-badge>\n </div>\n <div class=\"mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500\">\n <span>Created: <span class=\"text-gray-700\">{{ formatDate(ep.createdAt) }}</span></span>\n @if (ep.updatedAt) {\n <span>Updated: <span class=\"text-gray-700\">{{ formatDate(ep.updatedAt) }}</span></span>\n }\n <span>Last delivery: <span class=\"text-gray-700\">{{ formatDate(ep.stats?.lastDeliveryAt) }}</span></span>\n </div>\n }\n </div>\n <div class=\"flex flex-wrap items-center gap-2 shrink-0\">\n @if (!isNew()) {\n <ui-button variant=\"secondary\" [loading]=\"testing()\" (clicked)=\"testEndpoint()\">\n <ui-icon name=\"beaker\" class=\"h-4 w-4 mr-1.5\" />\n Send test event\n </ui-button>\n <ui-button variant=\"danger\" (clicked)=\"showDeleteConfirm.set(true)\">Delete</ui-button>\n }\n <ui-button variant=\"secondary\" (clicked)=\"onCancel()\">Cancel</ui-button>\n <ui-button [loading]=\"saving()\" (clicked)=\"onSave()\">{{ isNew() ? 'Create webhook' : 'Save changes' }}</ui-button>\n </div>\n </div>\n\n <!-- Tabs (existing endpoints) -->\n @if (tabs().length) {\n <div class=\"border-b border-gray-200\">\n <nav class=\"-mb-px flex space-x-6 overflow-x-auto\" role=\"tablist\" aria-label=\"Webhook sections\">\n @for (tab of tabs(); track tab.id) {\n <button\n type=\"button\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n [class]=\"activeTab() === tab.id\n ? 'border-primary-500 text-primary-600'\n : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n class=\"whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm\"\n (click)=\"setActiveTab(tab.id)\"\n >\n {{ tab.label }}\n @if (tab.id === 'deliveries' && endpoint()?.stats; as stats) {\n <span class=\"ml-1 text-xs text-gray-400\">{{ stats.totalDeliveries }}</span>\n }\n </button>\n }\n </nav>\n </div>\n }\n\n @if (activeTab() === 'settings') {\n <form (ngSubmit)=\"onSave()\" novalidate class=\"grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]\">\n <!-- Section index -->\n <aside class=\"hidden lg:block\" aria-label=\"On this page\">\n <ul class=\"sticky top-4 space-y-1 text-sm\">\n @for (s of sections; track s.id) {\n <li>\n <a\n [href]=\"'#webhook-' + s.id\"\n (click)=\"$event.preventDefault(); scrollToSection(s.id)\"\n class=\"block rounded px-2 py-1 text-gray-600 hover:bg-gray-100 hover:text-gray-900\"\n >{{ s.label }}</a>\n </li>\n }\n </ul>\n </aside>\n\n <div class=\"space-y-6 min-w-0 max-w-4xl\">\n <!-- 1. Basics -->\n <section id=\"webhook-basics\" class=\"scroll-mt-4\">\n <ui-card title=\"Basics\" subtitle=\"What this endpoint is and where deliveries go.\">\n <div class=\"grid gap-4 md:grid-cols-2\">\n <ui-input\n label=\"Name\"\n inputId=\"webhook-name\"\n [required]=\"true\"\n placeholder=\"Order sync\"\n [error]=\"fieldError('name')\"\n [(ngModel)]=\"formData.name\"\n name=\"name\"\n />\n <ui-input\n label=\"Description\"\n inputId=\"webhook-description\"\n placeholder=\"Optional\"\n [(ngModel)]=\"formData.description\"\n name=\"description\"\n />\n <div class=\"md:col-span-2\">\n <ui-input\n label=\"Endpoint URL\"\n inputId=\"webhook-url\"\n type=\"url\"\n [required]=\"true\"\n placeholder=\"https://api.example.com/webhooks\"\n hint=\"Deliveries are POST requests with a JSON body. Use HTTPS outside development.\"\n [error]=\"fieldError('url')\"\n [(ngModel)]=\"formData.url\"\n name=\"url\"\n />\n </div>\n <div class=\"md:col-span-2 flex items-start gap-3\">\n <input\n type=\"checkbox\"\n id=\"webhook-enabled\"\n name=\"enabled\"\n [(ngModel)]=\"formData.enabled\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <label for=\"webhook-enabled\" class=\"text-sm text-gray-700\">\n Endpoint is active\n <span class=\"block text-xs text-gray-500\">Turn this off to pause deliveries without losing the configuration.</span>\n </label>\n </div>\n </div>\n </ui-card>\n </section>\n\n <!-- 2. Events -->\n <section id=\"webhook-events\" class=\"scroll-mt-4\">\n <ui-card title=\"Events\" subtitle=\"Choose which events are delivered to this endpoint, grouped by module.\">\n <div class=\"space-y-3\">\n <div class=\"flex flex-col gap-2 sm:flex-row sm:items-center\">\n <div class=\"relative flex-1\">\n <label for=\"webhook-event-search\" class=\"sr-only\">Search events</label>\n <ui-icon name=\"search\" class=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400\" />\n <input\n id=\"webhook-event-search\"\n type=\"search\"\n [(ngModel)]=\"eventSearchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n placeholder=\"Search events\"\n class=\"pl-9 w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n />\n </div>\n <div class=\"flex items-center gap-1 text-sm\">\n <button type=\"button\" (click)=\"expandAllGroups()\" class=\"rounded px-2 py-1.5 text-gray-600 hover:bg-gray-100\">Expand all</button>\n <button type=\"button\" (click)=\"collapseAllGroups()\" class=\"rounded px-2 py-1.5 text-gray-600 hover:bg-gray-100\">Collapse all</button>\n @if (formData.eventTypes.length) {\n <button type=\"button\" (click)=\"clearEvents()\" class=\"rounded px-2 py-1.5 text-gray-600 hover:bg-gray-100\">Clear</button>\n }\n </div>\n </div>\n\n <p class=\"text-sm\" [class.text-gray-600]=\"!fieldError('eventTypes')\" [class.text-red-600]=\"!!fieldError('eventTypes')\" aria-live=\"polite\">\n @if (fieldError('eventTypes'); as err) {\n {{ err }}\n } @else {\n {{ formData.eventTypes.length }} {{ formData.eventTypes.length === 1 ? 'event' : 'events' }} selected\n }\n </p>\n\n <div class=\"rounded-md border border-gray-200 divide-y divide-gray-200\">\n @for (group of filteredGroups(); track group.name) {\n <div>\n <div class=\"flex items-center gap-3 bg-gray-50 px-3 py-2\">\n <button\n type=\"button\"\n class=\"flex items-center text-gray-500 hover:text-gray-700\"\n [attr.aria-expanded]=\"isGroupExpanded(group.name)\"\n [attr.aria-label]=\"(isGroupExpanded(group.name) ? 'Collapse ' : 'Expand ') + group.name\"\n (click)=\"toggleGroup(group.name)\"\n >\n <ui-icon [name]=\"isGroupExpanded(group.name) ? 'chevron-down' : 'chevron-right'\" class=\"h-4 w-4\" />\n </button>\n <input\n type=\"checkbox\"\n [id]=\"'group-' + group.name\"\n [checked]=\"isGroupFullySelected(group)\"\n [indeterminate]=\"isGroupPartlySelected(group)\"\n (change)=\"toggleGroupSelection(group)\"\n [attr.aria-label]=\"'Select all ' + group.name + ' events'\"\n class=\"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <button type=\"button\" class=\"flex-1 text-left text-sm font-medium text-gray-800\" (click)=\"toggleGroup(group.name)\">\n {{ group.name }}\n </button>\n <span class=\"text-xs text-gray-500\">{{ selectedCount(group) }} of {{ group.events.length }}</span>\n </div>\n @if (isGroupExpanded(group.name)) {\n <div class=\"grid gap-1 p-2 md:grid-cols-2\">\n @for (event of group.events; track event.eventType) {\n <label\n class=\"flex items-start gap-2 rounded px-2 py-2 text-sm hover:bg-gray-50\"\n [class.cursor-pointer]=\"canSelect(event) || isSelected(event.eventType)\"\n [class.opacity-60]=\"!canSelect(event) && !isSelected(event.eventType)\"\n [title]=\"carriesSensitiveData(event) ? restrictionTitle(event) : ''\"\n >\n <input\n type=\"checkbox\"\n [checked]=\"isSelected(event.eventType)\"\n [disabled]=\"!canSelect(event) && !isSelected(event.eventType)\"\n (change)=\"toggleEventType(event.eventType)\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"min-w-0 flex-1\">\n <span class=\"flex flex-wrap items-center gap-2 font-medium text-gray-900\">\n {{ event.displayName }}\n @if (event.restricted) {\n <ui-badge variant=\"danger\">Restricted</ui-badge>\n } @else if (carriesSensitiveData(event)) {\n <ui-badge variant=\"warning\">Sensitive fields</ui-badge>\n }\n </span>\n <span class=\"block font-mono text-xs text-gray-500 break-all\">{{ event.eventType }}</span>\n @if (!canSelect(event)) {\n <span class=\"block text-xs text-red-600\">Requires the {{ event.requiredPermission }} permission</span>\n }\n @if (event.description) {\n <span class=\"block text-xs text-gray-500\">{{ event.description }}</span>\n }\n </span>\n </label>\n }\n </div>\n }\n </div>\n } @empty {\n <div class=\"py-8 text-center text-sm text-gray-500\">\n @if (eventSearchTerm) {\n No events match \"{{ eventSearchTerm }}\"\n } @else {\n No events are available for this tenant.\n }\n </div>\n }\n </div>\n </div>\n </ui-card>\n </section>\n\n <!-- 3. Authentication -->\n <section id=\"webhook-authentication\" class=\"scroll-mt-4\">\n <ui-card title=\"Authentication\" subtitle=\"How each delivery proves it comes from Olu\u1E63\u1ECD.\">\n <fieldset class=\"space-y-3\">\n <legend class=\"sr-only\">Delivery authentication</legend>\n\n <!-- HMAC -->\n <label\n class=\"block cursor-pointer rounded-lg border p-4\"\n [class.border-primary-500]=\"authMode() === 'hmac'\"\n [class.ring-1]=\"authMode() === 'hmac'\"\n [class.ring-primary-500]=\"authMode() === 'hmac'\"\n [class.border-gray-200]=\"authMode() !== 'hmac'\"\n >\n <span class=\"flex items-start gap-3\">\n <input\n type=\"radio\"\n name=\"authMode\"\n value=\"hmac\"\n [checked]=\"authMode() === 'hmac'\"\n (change)=\"selectAuthMode('hmac')\"\n class=\"mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"flex-1\">\n <span class=\"block text-sm font-medium text-gray-900\">HMAC signature (shared secret)</span>\n <span class=\"block text-xs text-gray-500\">\n Each delivery is signed with a <code class=\"font-mono\">whsec_</code> secret that you give the receiver. The signature\n is sent in the X-Webhook-Signature header with the time in X-Webhook-Timestamp.\n </span>\n </span>\n </span>\n </label>\n\n <!-- Issuer-signed -->\n <label\n class=\"block cursor-pointer rounded-lg border p-4\"\n [class.border-primary-500]=\"authMode() === 'issuer'\"\n [class.ring-1]=\"authMode() === 'issuer'\"\n [class.ring-primary-500]=\"authMode() === 'issuer'\"\n [class.border-gray-200]=\"authMode() !== 'issuer'\"\n >\n <span class=\"flex items-start gap-3\">\n <input\n type=\"radio\"\n name=\"authMode\"\n value=\"issuer\"\n [checked]=\"authMode() === 'issuer'\"\n (change)=\"selectAuthMode('issuer')\"\n class=\"mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"flex-1\">\n <span class=\"flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900\">\n Signed by this tenant's issuer (no shared secret)\n <ui-badge variant=\"info\">New</ui-badge>\n </span>\n <span class=\"block text-xs text-gray-500\">\n Each delivery carries a short-lived token signed with this tenant's issuer key. The receiver checks it against the\n issuer's public keys (its JWKS); nothing secret is shared.\n </span>\n </span>\n </span>\n </label>\n\n <!-- Static credential -->\n <label\n class=\"block cursor-pointer rounded-lg border p-4\"\n [class.border-primary-500]=\"authMode() === 'static'\"\n [class.ring-1]=\"authMode() === 'static'\"\n [class.ring-primary-500]=\"authMode() === 'static'\"\n [class.border-gray-200]=\"authMode() !== 'static'\"\n >\n <span class=\"flex items-start gap-3\">\n <input\n type=\"radio\"\n name=\"authMode\"\n value=\"static\"\n [checked]=\"authMode() === 'static'\"\n (change)=\"selectAuthMode('static')\"\n class=\"mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"flex-1\">\n <span class=\"block text-sm font-medium text-gray-900\">Static credential</span>\n <span class=\"block text-xs text-gray-500\">\n A fixed bearer token, username and password, or API key, sent with every delivery for receivers that only check a\n header. Deliveries are also HMAC signed.\n </span>\n </span>\n </span>\n </label>\n </fieldset>\n\n <!-- Details of the chosen option -->\n <div class=\"mt-4 rounded-md bg-gray-50 p-4 space-y-3\">\n @switch (authMode()) {\n @case ('hmac') {\n @if (isNew()) {\n <p class=\"text-sm text-gray-600\">\n A <code class=\"font-mono\">whsec_</code> secret is generated when you create the webhook. It is shown once, so copy it then.\n </p>\n } @else {\n <div class=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n <div>\n <p class=\"text-sm font-medium text-gray-700\">Signing secret</p>\n <p class=\"font-mono text-sm text-gray-500\">whsec_\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022</p>\n <p class=\"text-xs text-gray-500\">Secrets are shown only when created or rotated.</p>\n </div>\n <ui-button variant=\"secondary\" (clicked)=\"showRotateConfirm.set(true)\">\n <ui-icon name=\"key\" class=\"h-4 w-4 mr-1.5\" />\n Rotate secret\n </ui-button>\n </div>\n }\n }\n @case ('issuer') {\n <div>\n <p class=\"text-sm font-medium text-gray-700\" id=\"webhook-jwks-label\">Issuer public keys (JWKS)</p>\n <div class=\"mt-1 flex items-center gap-2\">\n <code class=\"flex-1 min-w-0 break-all rounded border border-gray-200 bg-white px-2 py-1.5 font-mono text-xs text-gray-800\" aria-labelledby=\"webhook-jwks-label\">\n {{ jwksUrl() || 'Resolving the issuer...' }}\n </code>\n <button\n type=\"button\"\n class=\"rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-50\"\n [disabled]=\"!jwksUrl()\"\n (click)=\"copy(jwksUrl())\"\n aria-label=\"Copy the JWKS URL\"\n title=\"Copy\"\n >\n <ui-icon name=\"document-duplicate\" class=\"h-4 w-4\" />\n </button>\n </div>\n <p class=\"mt-1 text-xs text-gray-500\">\n @if (jwksFromDiscovery()) {\n From this tenant's discovery document.\n } @else {\n The default location for this tenant's issuer; check it against the issuer's discovery document if the receiver\n cannot fetch it.\n }\n Give the receiver this URL. Keys rotate with the tenant's signing keys, so the receiver should refetch it when it meets an\n unknown key.\n </p>\n </div>\n @if (!formData.clientId) {\n <p class=\"text-xs text-amber-700\">Choose an owning client below; the token names it as its subject.</p>\n }\n }\n @case ('static') {\n <div class=\"grid gap-4 md:grid-cols-2\">\n <div class=\"md:col-span-2\">\n <span class=\"block text-sm font-medium text-gray-700 mb-2\" id=\"webhook-static-type\">Credential type</span>\n <div class=\"flex flex-wrap gap-2\" role=\"radiogroup\" aria-labelledby=\"webhook-static-type\">\n @for (option of staticAuthOptions; track option.type) {\n <label\n class=\"flex cursor-pointer items-center gap-2 rounded-md border bg-white px-3 py-2 text-sm\"\n [class.border-primary-500]=\"formData.authType === option.type\"\n [class.border-gray-300]=\"formData.authType !== option.type\"\n >\n <input\n type=\"radio\"\n name=\"staticAuthType\"\n [checked]=\"formData.authType === option.type\"\n (change)=\"formData.authType = option.type\"\n class=\"h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n {{ option.label }}\n </label>\n }\n </div>\n @for (option of staticAuthOptions; track option.type) {\n @if (formData.authType === option.type) {\n <p class=\"mt-1 font-mono text-xs text-gray-500\">{{ option.hint }}</p>\n }\n }\n </div>\n\n @if (formData.authType === AuthType.BasicAuth) {\n <ui-input\n label=\"Username\"\n inputId=\"webhook-basic-username\"\n [placeholder]=\"credentialStored() ? 'Leave blank to keep the current one' : ''\"\n [(ngModel)]=\"formData.basicUsername\"\n name=\"basicUsername\"\n />\n <ui-input\n label=\"Password\"\n inputId=\"webhook-basic-password\"\n type=\"password\"\n [placeholder]=\"credentialStored() ? 'Leave blank to keep the current one' : ''\"\n [(ngModel)]=\"formData.basicPassword\"\n name=\"basicPassword\"\n />\n } @else {\n <div class=\"md:col-span-2\">\n <ui-input\n [label]=\"formData.authType === AuthType.BearerToken ? 'Token' : 'API key'\"\n inputId=\"webhook-credential\"\n type=\"password\"\n [placeholder]=\"credentialStored() ? 'Leave blank to keep the current one' : ''\"\n [(ngModel)]=\"formData.credential\"\n name=\"credential\"\n />\n </div>\n }\n <div class=\"md:col-span-2\">\n @if (fieldError('credential'); as err) {\n <p class=\"text-sm text-red-600\">{{ err }}</p>\n } @else {\n <p class=\"text-xs text-gray-500\">Stored encrypted and never shown again.</p>\n }\n </div>\n </div>\n @if (!isNew()) {\n <div class=\"flex flex-col gap-2 border-t border-gray-200 pt-3 sm:flex-row sm:items-center sm:justify-between\">\n <p class=\"text-xs text-gray-500\">The HMAC signing secret is also sent with each delivery.</p>\n <ui-button variant=\"secondary\" size=\"sm\" (clicked)=\"showRotateConfirm.set(true)\">\n <ui-icon name=\"key\" class=\"h-4 w-4 mr-1.5\" />\n Rotate secret\n </ui-button>\n </div>\n }\n }\n }\n @if (leavingIssuerSigned()) {\n <p class=\"text-xs text-amber-700\">After saving, rotate the secret to get a signing secret for the receiver.</p>\n }\n </div>\n </ui-card>\n </section>\n\n <!-- 4. Owning client -->\n <section id=\"webhook-client\" class=\"scroll-mt-4\">\n <ui-card\n title=\"Owning client\"\n subtitle=\"The client this endpoint belongs to. Events this client causes itself (for example comments it writes through the Service API) are not sent back to it. With issuer-signed delivery, the token names this client as its subject.\"\n >\n <div class=\"space-y-4\">\n <div class=\"max-w-xl\">\n <label for=\"webhook-client-id\" class=\"block text-sm font-medium text-gray-700 mb-1\">Client</label>\n <select\n id=\"webhook-client-id\"\n name=\"clientId\"\n [(ngModel)]=\"formData.clientId\"\n class=\"w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n >\n <option value=\"\">None</option>\n @for (client of clients(); track client.clientId) {\n <option [value]=\"client.clientId\">{{ clientLabel(client.clientId) }}</option>\n }\n </select>\n </div>\n\n @if (formData.clientId) {\n <div class=\"flex items-start gap-3\">\n <input\n type=\"checkbox\"\n id=\"webhook-skip-own\"\n name=\"skipOwnClientEvents\"\n [(ngModel)]=\"formData.skipOwnClientEvents\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <label for=\"webhook-skip-own\" class=\"text-sm text-gray-700\">\n Skip own client events\n <span class=\"block text-xs text-gray-500\">\n Don't send changes this client made itself. This stops the receiver being notified about its own updates, which can\n otherwise loop.\n </span>\n </label>\n </div>\n }\n </div>\n </ui-card>\n </section>\n\n <!-- 5. Sensitive data -->\n <section id=\"webhook-sensitive\" class=\"scroll-mt-4\">\n <ui-card title=\"Sensitive data\" subtitle=\"Confidential fields of restricted events are withheld unless you authorise this endpoint.\">\n @if (sensitiveEventsOf(formData.eventTypes).length > 0) {\n <div class=\"rounded-md border border-amber-300 bg-amber-50 p-3\">\n <div class=\"flex items-start gap-3\">\n <input\n type=\"checkbox\"\n id=\"webhook-authorize-sensitive\"\n name=\"authorizeSensitiveData\"\n [checked]=\"formData.authorizeSensitiveData\"\n [disabled]=\"!canAuthorizeSensitiveData()\"\n (change)=\"onAuthorizeSensitiveDataChange($any($event.target).checked)\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <label for=\"webhook-authorize-sensitive\" class=\"text-sm text-gray-800\">\n Send sensitive data to this endpoint\n <span class=\"block text-xs text-gray-600\">\n Without this, the confidential fields of restricted events are withheld from deliveries and listed in\n <code>redacted_fields</code>.\n @if (!canAuthorizeSensitiveData()) {\n You need the permission each restricted event declares to authorise this.\n }\n </span>\n @if (endpoint()?.sensitiveDataAuthorizedAt) {\n <span class=\"block text-xs text-gray-600\">{{ authorizationTitle(endpoint()!) }}</span>\n }\n </label>\n </div>\n <ul class=\"mt-2 ml-7 list-disc text-xs text-gray-600\">\n @for (event of sensitiveEventsOf(formData.eventTypes); track event.eventType) {\n <li>{{ event.displayName }}</li>\n }\n </ul>\n </div>\n } @else {\n <p class=\"text-sm text-gray-500\">None of the selected events carry sensitive fields.</p>\n }\n </ui-card>\n </section>\n\n <!-- 6. Headers and API version -->\n <section id=\"webhook-advanced\" class=\"scroll-mt-4\">\n <ui-card title=\"Headers and version\" subtitle=\"Extra headers sent with every delivery, and the payload format.\">\n <div class=\"space-y-4\">\n <div>\n <span class=\"block text-sm font-medium text-gray-700 mb-2\">Custom headers</span>\n @if (formData.headers.length) {\n <div class=\"space-y-2\">\n @for (row of formData.headers; track $index; let i = $index) {\n <div class=\"grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-2\">\n <input\n type=\"text\"\n [(ngModel)]=\"row.name\"\n [name]=\"'headerName' + i\"\n placeholder=\"X-Header-Name\"\n [attr.aria-label]=\"'Header ' + (i + 1) + ' name'\"\n class=\"w-full rounded-md border-gray-300 shadow-sm font-mono focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n />\n <input\n type=\"text\"\n [(ngModel)]=\"row.value\"\n [name]=\"'headerValue' + i\"\n placeholder=\"Value\"\n [attr.aria-label]=\"'Header ' + (i + 1) + ' value'\"\n class=\"w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n />\n <button\n type=\"button\"\n (click)=\"removeHeader(i)\"\n class=\"rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600\"\n [attr.aria-label]=\"'Remove header ' + (i + 1)\"\n >\n <ui-icon name=\"trash\" class=\"h-4 w-4\" />\n </button>\n </div>\n }\n </div>\n } @else {\n <p class=\"text-sm text-gray-500\">No custom headers.</p>\n }\n @if (fieldError('headers'); as err) {\n <p class=\"mt-2 text-sm text-red-600\">{{ err }}</p>\n }\n <button type=\"button\" (click)=\"addHeader()\" class=\"mt-2 inline-flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700\">\n <ui-icon name=\"plus\" class=\"h-4 w-4\" />\n Add header\n </button>\n <p class=\"mt-1 text-xs text-gray-500\">Don't put secrets here; header values are stored and shown as plain text.</p>\n </div>\n <div>\n <span class=\"block text-sm font-medium text-gray-700\">API version</span>\n <p class=\"mt-1 font-mono text-sm text-gray-800\">{{ endpoint()?.apiVersion || defaultApiVersion }}</p>\n <p class=\"text-xs text-gray-500\">The payload format deliveries use.</p>\n </div>\n </div>\n </ui-card>\n </section>\n\n <!-- Save bar -->\n <div class=\"sticky bottom-0 z-10 -mx-4 flex justify-end gap-2 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur sm:mx-0 sm:rounded-lg sm:border sm:shadow-sm\">\n <ui-button variant=\"secondary\" (clicked)=\"onCancel()\">Cancel</ui-button>\n <ui-button type=\"submit\" [loading]=\"saving()\">{{ isNew() ? 'Create webhook' : 'Save changes' }}</ui-button>\n </div>\n </div>\n </form>\n }\n\n @if (activeTab() === 'deliveries' && endpoint(); as ep) {\n <div class=\"space-y-6\">\n <div class=\"grid gap-4 sm:grid-cols-3\">\n <ui-card>\n <p class=\"text-sm text-gray-500\">Total deliveries</p>\n <p class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ ep.stats?.totalDeliveries || 0 }}</p>\n </ui-card>\n <ui-card>\n <p class=\"text-sm text-gray-500\">Succeeded</p>\n <p class=\"mt-1 text-2xl font-semibold text-green-600\">{{ ep.stats?.successfulDeliveries || 0 }}</p>\n <p class=\"text-xs text-gray-500\">Last: {{ formatDate(ep.stats?.lastSuccessAt) }}</p>\n </ui-card>\n <ui-card>\n <p class=\"text-sm text-gray-500\">Failed</p>\n <p class=\"mt-1 text-2xl font-semibold text-red-600\">{{ ep.stats?.failedDeliveries || 0 }}</p>\n <p class=\"text-xs text-gray-500\">Last: {{ formatDate(ep.stats?.lastFailureAt) }}</p>\n </ui-card>\n </div>\n\n <ui-card [padding]=\"false\">\n <div class=\"flex items-center justify-between border-b border-gray-200 px-4 py-3 sm:px-6\">\n <h2 class=\"text-lg font-medium text-gray-900\">Delivery history</h2>\n <ui-button variant=\"secondary\" size=\"sm\" [loading]=\"deliveriesLoading()\" (clicked)=\"loadDeliveries()\">\n <ui-icon name=\"arrow-path\" class=\"h-4 w-4 mr-1.5\" />\n Refresh\n </ui-button>\n </div>\n @if (deliveriesLoading() && !deliveriesLoaded()) {\n <div class=\"py-8 text-center text-gray-500\">Loading...</div>\n } @else if (deliveries().length > 0) {\n <div class=\"overflow-x-auto\">\n <table class=\"min-w-full divide-y divide-gray-200\">\n <thead class=\"bg-gray-50\">\n <tr>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Event</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Status</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">HTTP</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Attempts</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Time</th>\n <th scope=\"col\" class=\"px-4 py-2\"><span class=\"sr-only\">Actions</span></th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-200 bg-white\">\n @for (delivery of deliveries(); track delivery.id) {\n <tr>\n <td class=\"px-4 py-2 font-mono text-sm\">{{ delivery.eventType }}</td>\n <td class=\"px-4 py-2\">\n <ui-badge [variant]=\"getStatusVariant(delivery.status)\">{{ delivery.status }}</ui-badge>\n @if (delivery.errorMessage) {\n <span class=\"mt-0.5 block max-w-xs truncate text-xs text-red-600\" [title]=\"delivery.errorMessage\">{{ delivery.errorMessage }}</span>\n }\n </td>\n <td class=\"px-4 py-2 text-sm\">{{ delivery.httpStatus || '-' }}</td>\n <td class=\"px-4 py-2 text-sm\">{{ delivery.retryCount + 1 }}</td>\n <td class=\"px-4 py-2 text-sm text-gray-500 whitespace-nowrap\">{{ formatDate(delivery.createdAt) }}</td>\n <td class=\"px-4 py-2 text-right\">\n @if (canRetry(delivery.status)) {\n <button\n type=\"button\"\n (click)=\"retryDelivery(delivery)\"\n [disabled]=\"retryingId() === delivery.id\"\n class=\"rounded p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50\"\n aria-label=\"Retry delivery\"\n title=\"Retry\"\n >\n <ui-icon name=\"refresh\" class=\"h-4 w-4\" />\n </button>\n }\n </td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n } @else {\n <p class=\"py-8 text-center text-gray-500\">No deliveries yet. Send a test event to try the endpoint.</p>\n }\n </ui-card>\n </div>\n }\n </div>\n\n <!-- Dialogs -->\n <ui-confirm-dialog\n [open]=\"showSensitiveDataConfirm()\"\n title=\"Send sensitive data outside the platform?\"\n [message]=\"sensitiveDataConfirmMessage()\"\n confirmText=\"Authorise\"\n cancelText=\"Cancel\"\n variant=\"danger\"\n [closeOnBackdrop]=\"false\"\n (confirmed)=\"confirmSensitiveData()\"\n (cancelled)=\"cancelSensitiveData()\"\n />\n\n <ui-confirm-dialog\n [open]=\"showDeleteConfirm()\"\n title=\"Delete webhook\"\n [message]=\"'Delete the webhook \\'' + (endpoint()?.name || '') + '\\'? Deliveries stop immediately and this cannot be undone.'\"\n confirmText=\"Delete\"\n cancelText=\"Cancel\"\n variant=\"danger\"\n [loading]=\"deleting()\"\n [closeOnBackdrop]=\"false\"\n (confirmed)=\"confirmDelete()\"\n (cancelled)=\"showDeleteConfirm.set(false)\"\n />\n\n <ui-confirm-dialog\n [open]=\"showRotateConfirm()\"\n title=\"Rotate signing secret\"\n [message]=\"'Rotate the secret for \\'' + (endpoint()?.name || '') + '\\'? The current secret stops working immediately, so update the receiver with the new one.'\"\n confirmText=\"Rotate secret\"\n cancelText=\"Cancel\"\n variant=\"warning\"\n [loading]=\"rotating()\"\n [closeOnBackdrop]=\"false\"\n (confirmed)=\"confirmRotateSecret()\"\n (cancelled)=\"showRotateConfirm.set(false)\"\n />\n\n <ui-modal [open]=\"showSecretModal()\" title=\"Signing secret\" [closeOnBackdrop]=\"false\" (closed)=\"closeSecretModal()\">\n <div class=\"space-y-4\">\n <p class=\"text-sm text-amber-700\">This is the only time this secret is shown. Copy it now.</p>\n <div class=\"flex items-center gap-2\">\n <code class=\"flex-1 break-all rounded bg-gray-100 px-3 py-2 font-mono text-sm\">{{ newSecret() }}</code>\n <ui-button variant=\"secondary\" (clicked)=\"copySecret()\" [attr.aria-label]=\"'Copy secret'\">\n @if (secretCopied()) {\n <ui-icon name=\"check-circle\" class=\"h-5 w-5 text-green-500\" />\n } @else {\n <ui-icon name=\"document-duplicate\" class=\"h-5 w-5\" />\n }\n </ui-button>\n </div>\n <p class=\"text-xs text-gray-500\">\n Use this secret to verify webhook signatures. The signature is sent in the X-Webhook-Signature header as\n sha256=&lt;hex-signature&gt;.\n </p>\n <div class=\"flex justify-end\">\n <ui-button (clicked)=\"closeSecretModal()\">Done</ui-button>\n </div>\n </div>\n </ui-modal>\n}\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: CardComponent, selector: "ui-card", inputs: ["title", "subtitle", "padding", "shadow"] }, { kind: "component", type: ButtonComponent, selector: "ui-button", inputs: ["variant", "size", "disabled", "loading", "type", "fullWidth"], outputs: ["clicked"] }, { kind: "component", type: BadgeComponent, selector: "ui-badge", inputs: ["variant", "size"] }, { kind: "component", type: ModalComponent, selector: "ui-modal", inputs: ["open", "title", "size", "showCloseButton", "closeOnBackdrop", "closeOnEscape", "scrollable"], outputs: ["closed"] }, { kind: "component", type: ConfirmDialogComponent, selector: "ui-confirm-dialog", inputs: ["open", "title", "message", "confirmText", "cancelText", "variant", "loading", "closeOnBackdrop", "closeOnEscape"], outputs: ["confirmed", "cancelled"] }, { kind: "component", type: IconComponent, selector: "ui-icon", inputs: ["name", "strokeWidth"] }, { kind: "component", type: InputComponent, selector: "ui-input", inputs: ["type", "label", "placeholder", "disabled", "readonly", "required", "error", "hint", "inputId", "prefixIcon", "suffixIcon"], outputs: ["valueChange"] }] });
710
+ }
711
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: WebhookDetailPageComponent, decorators: [{
712
+ type: Component,
713
+ args: [{ selector: 'admin-webhook-detail-page', standalone: true, imports: [
714
+ CommonModule,
715
+ FormsModule,
716
+ RouterLink,
717
+ CardComponent,
718
+ ButtonComponent,
719
+ BadgeComponent,
720
+ ModalComponent,
721
+ ConfirmDialogComponent,
722
+ IconComponent,
723
+ InputComponent,
724
+ ], template: "@if (loading()) {\n <div class=\"flex justify-center py-12\" role=\"status\" aria-label=\"Loading\">\n <svg class=\"animate-spin h-8 w-8 text-primary-600\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n </div>\n} @else if (!isNew() && !endpoint()) {\n <div class=\"flex flex-col items-center justify-center py-16 text-gray-500\">\n <ui-icon name=\"link\" class=\"h-12 w-12 mb-4\" />\n <p>This webhook could not be found.</p>\n <a routerLink=\"/webhooks\" class=\"mt-2 text-sm text-primary-600 hover:text-primary-700\">Back to webhooks</a>\n </div>\n} @else {\n <div class=\"space-y-6\">\n <!-- Breadcrumb -->\n <nav aria-label=\"Breadcrumb\" class=\"text-sm\">\n <ol class=\"flex items-center gap-1 text-gray-500\">\n <li><a routerLink=\"/webhooks\" class=\"hover:text-gray-700\">Webhooks</a></li>\n <li aria-hidden=\"true\"><ui-icon name=\"chevron-right\" class=\"h-4 w-4\" /></li>\n <li class=\"text-gray-900 truncate\" aria-current=\"page\">{{ pageTitle() }}</li>\n </ol>\n </nav>\n\n <!-- Page header -->\n <div class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\">\n <div class=\"min-w-0\">\n <h1 class=\"text-2xl font-bold text-gray-900 truncate\">{{ pageTitle() }}</h1>\n @if (isNew()) {\n <p class=\"mt-1 text-sm text-gray-500\">Send events from this tenant to a URL you control.</p>\n } @else if (endpoint(); as ep) {\n <div class=\"mt-1 flex flex-wrap items-center gap-2\">\n <span class=\"font-mono text-xs text-gray-600 break-all\">{{ ep.url }}</span>\n <ui-badge [variant]=\"ep.enabled ? 'success' : 'default'\">{{ ep.enabled ? 'Active' : 'Disabled' }}</ui-badge>\n </div>\n <div class=\"mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500\">\n <span>Created: <span class=\"text-gray-700\">{{ formatDate(ep.createdAt) }}</span></span>\n @if (ep.updatedAt) {\n <span>Updated: <span class=\"text-gray-700\">{{ formatDate(ep.updatedAt) }}</span></span>\n }\n <span>Last delivery: <span class=\"text-gray-700\">{{ formatDate(ep.stats?.lastDeliveryAt) }}</span></span>\n </div>\n }\n </div>\n <div class=\"flex flex-wrap items-center gap-2 shrink-0\">\n @if (!isNew()) {\n <ui-button variant=\"secondary\" [loading]=\"testing()\" (clicked)=\"testEndpoint()\">\n <ui-icon name=\"beaker\" class=\"h-4 w-4 mr-1.5\" />\n Send test event\n </ui-button>\n <ui-button variant=\"danger\" (clicked)=\"showDeleteConfirm.set(true)\">Delete</ui-button>\n }\n <ui-button variant=\"secondary\" (clicked)=\"onCancel()\">Cancel</ui-button>\n <ui-button [loading]=\"saving()\" (clicked)=\"onSave()\">{{ isNew() ? 'Create webhook' : 'Save changes' }}</ui-button>\n </div>\n </div>\n\n <!-- Tabs (existing endpoints) -->\n @if (tabs().length) {\n <div class=\"border-b border-gray-200\">\n <nav class=\"-mb-px flex space-x-6 overflow-x-auto\" role=\"tablist\" aria-label=\"Webhook sections\">\n @for (tab of tabs(); track tab.id) {\n <button\n type=\"button\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n [class]=\"activeTab() === tab.id\n ? 'border-primary-500 text-primary-600'\n : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n class=\"whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm\"\n (click)=\"setActiveTab(tab.id)\"\n >\n {{ tab.label }}\n @if (tab.id === 'deliveries' && endpoint()?.stats; as stats) {\n <span class=\"ml-1 text-xs text-gray-400\">{{ stats.totalDeliveries }}</span>\n }\n </button>\n }\n </nav>\n </div>\n }\n\n @if (activeTab() === 'settings') {\n <form (ngSubmit)=\"onSave()\" novalidate class=\"grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]\">\n <!-- Section index -->\n <aside class=\"hidden lg:block\" aria-label=\"On this page\">\n <ul class=\"sticky top-4 space-y-1 text-sm\">\n @for (s of sections; track s.id) {\n <li>\n <a\n [href]=\"'#webhook-' + s.id\"\n (click)=\"$event.preventDefault(); scrollToSection(s.id)\"\n class=\"block rounded px-2 py-1 text-gray-600 hover:bg-gray-100 hover:text-gray-900\"\n >{{ s.label }}</a>\n </li>\n }\n </ul>\n </aside>\n\n <div class=\"space-y-6 min-w-0 max-w-4xl\">\n <!-- 1. Basics -->\n <section id=\"webhook-basics\" class=\"scroll-mt-4\">\n <ui-card title=\"Basics\" subtitle=\"What this endpoint is and where deliveries go.\">\n <div class=\"grid gap-4 md:grid-cols-2\">\n <ui-input\n label=\"Name\"\n inputId=\"webhook-name\"\n [required]=\"true\"\n placeholder=\"Order sync\"\n [error]=\"fieldError('name')\"\n [(ngModel)]=\"formData.name\"\n name=\"name\"\n />\n <ui-input\n label=\"Description\"\n inputId=\"webhook-description\"\n placeholder=\"Optional\"\n [(ngModel)]=\"formData.description\"\n name=\"description\"\n />\n <div class=\"md:col-span-2\">\n <ui-input\n label=\"Endpoint URL\"\n inputId=\"webhook-url\"\n type=\"url\"\n [required]=\"true\"\n placeholder=\"https://api.example.com/webhooks\"\n hint=\"Deliveries are POST requests with a JSON body. Use HTTPS outside development.\"\n [error]=\"fieldError('url')\"\n [(ngModel)]=\"formData.url\"\n name=\"url\"\n />\n </div>\n <div class=\"md:col-span-2 flex items-start gap-3\">\n <input\n type=\"checkbox\"\n id=\"webhook-enabled\"\n name=\"enabled\"\n [(ngModel)]=\"formData.enabled\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <label for=\"webhook-enabled\" class=\"text-sm text-gray-700\">\n Endpoint is active\n <span class=\"block text-xs text-gray-500\">Turn this off to pause deliveries without losing the configuration.</span>\n </label>\n </div>\n </div>\n </ui-card>\n </section>\n\n <!-- 2. Events -->\n <section id=\"webhook-events\" class=\"scroll-mt-4\">\n <ui-card title=\"Events\" subtitle=\"Choose which events are delivered to this endpoint, grouped by module.\">\n <div class=\"space-y-3\">\n <div class=\"flex flex-col gap-2 sm:flex-row sm:items-center\">\n <div class=\"relative flex-1\">\n <label for=\"webhook-event-search\" class=\"sr-only\">Search events</label>\n <ui-icon name=\"search\" class=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400\" />\n <input\n id=\"webhook-event-search\"\n type=\"search\"\n [(ngModel)]=\"eventSearchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n placeholder=\"Search events\"\n class=\"pl-9 w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n />\n </div>\n <div class=\"flex items-center gap-1 text-sm\">\n <button type=\"button\" (click)=\"expandAllGroups()\" class=\"rounded px-2 py-1.5 text-gray-600 hover:bg-gray-100\">Expand all</button>\n <button type=\"button\" (click)=\"collapseAllGroups()\" class=\"rounded px-2 py-1.5 text-gray-600 hover:bg-gray-100\">Collapse all</button>\n @if (formData.eventTypes.length) {\n <button type=\"button\" (click)=\"clearEvents()\" class=\"rounded px-2 py-1.5 text-gray-600 hover:bg-gray-100\">Clear</button>\n }\n </div>\n </div>\n\n <p class=\"text-sm\" [class.text-gray-600]=\"!fieldError('eventTypes')\" [class.text-red-600]=\"!!fieldError('eventTypes')\" aria-live=\"polite\">\n @if (fieldError('eventTypes'); as err) {\n {{ err }}\n } @else {\n {{ formData.eventTypes.length }} {{ formData.eventTypes.length === 1 ? 'event' : 'events' }} selected\n }\n </p>\n\n <div class=\"rounded-md border border-gray-200 divide-y divide-gray-200\">\n @for (group of filteredGroups(); track group.name) {\n <div>\n <div class=\"flex items-center gap-3 bg-gray-50 px-3 py-2\">\n <button\n type=\"button\"\n class=\"flex items-center text-gray-500 hover:text-gray-700\"\n [attr.aria-expanded]=\"isGroupExpanded(group.name)\"\n [attr.aria-label]=\"(isGroupExpanded(group.name) ? 'Collapse ' : 'Expand ') + group.name\"\n (click)=\"toggleGroup(group.name)\"\n >\n <ui-icon [name]=\"isGroupExpanded(group.name) ? 'chevron-down' : 'chevron-right'\" class=\"h-4 w-4\" />\n </button>\n <input\n type=\"checkbox\"\n [id]=\"'group-' + group.name\"\n [checked]=\"isGroupFullySelected(group)\"\n [indeterminate]=\"isGroupPartlySelected(group)\"\n (change)=\"toggleGroupSelection(group)\"\n [attr.aria-label]=\"'Select all ' + group.name + ' events'\"\n class=\"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <button type=\"button\" class=\"flex-1 text-left text-sm font-medium text-gray-800\" (click)=\"toggleGroup(group.name)\">\n {{ group.name }}\n </button>\n <span class=\"text-xs text-gray-500\">{{ selectedCount(group) }} of {{ group.events.length }}</span>\n </div>\n @if (isGroupExpanded(group.name)) {\n <div class=\"grid gap-1 p-2 md:grid-cols-2\">\n @for (event of group.events; track event.eventType) {\n <label\n class=\"flex items-start gap-2 rounded px-2 py-2 text-sm hover:bg-gray-50\"\n [class.cursor-pointer]=\"canSelect(event) || isSelected(event.eventType)\"\n [class.opacity-60]=\"!canSelect(event) && !isSelected(event.eventType)\"\n [title]=\"carriesSensitiveData(event) ? restrictionTitle(event) : ''\"\n >\n <input\n type=\"checkbox\"\n [checked]=\"isSelected(event.eventType)\"\n [disabled]=\"!canSelect(event) && !isSelected(event.eventType)\"\n (change)=\"toggleEventType(event.eventType)\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"min-w-0 flex-1\">\n <span class=\"flex flex-wrap items-center gap-2 font-medium text-gray-900\">\n {{ event.displayName }}\n @if (event.restricted) {\n <ui-badge variant=\"danger\">Restricted</ui-badge>\n } @else if (carriesSensitiveData(event)) {\n <ui-badge variant=\"warning\">Sensitive fields</ui-badge>\n }\n </span>\n <span class=\"block font-mono text-xs text-gray-500 break-all\">{{ event.eventType }}</span>\n @if (!canSelect(event)) {\n <span class=\"block text-xs text-red-600\">Requires the {{ event.requiredPermission }} permission</span>\n }\n @if (event.description) {\n <span class=\"block text-xs text-gray-500\">{{ event.description }}</span>\n }\n </span>\n </label>\n }\n </div>\n }\n </div>\n } @empty {\n <div class=\"py-8 text-center text-sm text-gray-500\">\n @if (eventSearchTerm) {\n No events match \"{{ eventSearchTerm }}\"\n } @else {\n No events are available for this tenant.\n }\n </div>\n }\n </div>\n </div>\n </ui-card>\n </section>\n\n <!-- 3. Authentication -->\n <section id=\"webhook-authentication\" class=\"scroll-mt-4\">\n <ui-card title=\"Authentication\" subtitle=\"How each delivery proves it comes from Olu\u1E63\u1ECD.\">\n <fieldset class=\"space-y-3\">\n <legend class=\"sr-only\">Delivery authentication</legend>\n\n <!-- HMAC -->\n <label\n class=\"block cursor-pointer rounded-lg border p-4\"\n [class.border-primary-500]=\"authMode() === 'hmac'\"\n [class.ring-1]=\"authMode() === 'hmac'\"\n [class.ring-primary-500]=\"authMode() === 'hmac'\"\n [class.border-gray-200]=\"authMode() !== 'hmac'\"\n >\n <span class=\"flex items-start gap-3\">\n <input\n type=\"radio\"\n name=\"authMode\"\n value=\"hmac\"\n [checked]=\"authMode() === 'hmac'\"\n (change)=\"selectAuthMode('hmac')\"\n class=\"mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"flex-1\">\n <span class=\"block text-sm font-medium text-gray-900\">HMAC signature (shared secret)</span>\n <span class=\"block text-xs text-gray-500\">\n Each delivery is signed with a <code class=\"font-mono\">whsec_</code> secret that you give the receiver. The signature\n is sent in the X-Webhook-Signature header with the time in X-Webhook-Timestamp.\n </span>\n </span>\n </span>\n </label>\n\n <!-- Issuer-signed -->\n <label\n class=\"block cursor-pointer rounded-lg border p-4\"\n [class.border-primary-500]=\"authMode() === 'issuer'\"\n [class.ring-1]=\"authMode() === 'issuer'\"\n [class.ring-primary-500]=\"authMode() === 'issuer'\"\n [class.border-gray-200]=\"authMode() !== 'issuer'\"\n >\n <span class=\"flex items-start gap-3\">\n <input\n type=\"radio\"\n name=\"authMode\"\n value=\"issuer\"\n [checked]=\"authMode() === 'issuer'\"\n (change)=\"selectAuthMode('issuer')\"\n class=\"mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"flex-1\">\n <span class=\"flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900\">\n Signed by this tenant's issuer (no shared secret)\n <ui-badge variant=\"info\">New</ui-badge>\n </span>\n <span class=\"block text-xs text-gray-500\">\n Each delivery carries a short-lived token signed with this tenant's issuer key. The receiver checks it against the\n issuer's public keys (its JWKS); nothing secret is shared.\n </span>\n </span>\n </span>\n </label>\n\n <!-- Static credential -->\n <label\n class=\"block cursor-pointer rounded-lg border p-4\"\n [class.border-primary-500]=\"authMode() === 'static'\"\n [class.ring-1]=\"authMode() === 'static'\"\n [class.ring-primary-500]=\"authMode() === 'static'\"\n [class.border-gray-200]=\"authMode() !== 'static'\"\n >\n <span class=\"flex items-start gap-3\">\n <input\n type=\"radio\"\n name=\"authMode\"\n value=\"static\"\n [checked]=\"authMode() === 'static'\"\n (change)=\"selectAuthMode('static')\"\n class=\"mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <span class=\"flex-1\">\n <span class=\"block text-sm font-medium text-gray-900\">Static credential</span>\n <span class=\"block text-xs text-gray-500\">\n A fixed bearer token, username and password, or API key, sent with every delivery for receivers that only check a\n header. Deliveries are also HMAC signed.\n </span>\n </span>\n </span>\n </label>\n </fieldset>\n\n <!-- Details of the chosen option -->\n <div class=\"mt-4 rounded-md bg-gray-50 p-4 space-y-3\">\n @switch (authMode()) {\n @case ('hmac') {\n @if (isNew()) {\n <p class=\"text-sm text-gray-600\">\n A <code class=\"font-mono\">whsec_</code> secret is generated when you create the webhook. It is shown once, so copy it then.\n </p>\n } @else {\n <div class=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n <div>\n <p class=\"text-sm font-medium text-gray-700\">Signing secret</p>\n <p class=\"font-mono text-sm text-gray-500\">whsec_\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022</p>\n <p class=\"text-xs text-gray-500\">Secrets are shown only when created or rotated.</p>\n </div>\n <ui-button variant=\"secondary\" (clicked)=\"showRotateConfirm.set(true)\">\n <ui-icon name=\"key\" class=\"h-4 w-4 mr-1.5\" />\n Rotate secret\n </ui-button>\n </div>\n }\n }\n @case ('issuer') {\n <div>\n <p class=\"text-sm font-medium text-gray-700\" id=\"webhook-jwks-label\">Issuer public keys (JWKS)</p>\n <div class=\"mt-1 flex items-center gap-2\">\n <code class=\"flex-1 min-w-0 break-all rounded border border-gray-200 bg-white px-2 py-1.5 font-mono text-xs text-gray-800\" aria-labelledby=\"webhook-jwks-label\">\n {{ jwksUrl() || 'Resolving the issuer...' }}\n </code>\n <button\n type=\"button\"\n class=\"rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-50\"\n [disabled]=\"!jwksUrl()\"\n (click)=\"copy(jwksUrl())\"\n aria-label=\"Copy the JWKS URL\"\n title=\"Copy\"\n >\n <ui-icon name=\"document-duplicate\" class=\"h-4 w-4\" />\n </button>\n </div>\n <p class=\"mt-1 text-xs text-gray-500\">\n @if (jwksFromDiscovery()) {\n From this tenant's discovery document.\n } @else {\n The default location for this tenant's issuer; check it against the issuer's discovery document if the receiver\n cannot fetch it.\n }\n Give the receiver this URL. Keys rotate with the tenant's signing keys, so the receiver should refetch it when it meets an\n unknown key.\n </p>\n </div>\n @if (!formData.clientId) {\n <p class=\"text-xs text-amber-700\">Choose an owning client below; the token names it as its subject.</p>\n }\n }\n @case ('static') {\n <div class=\"grid gap-4 md:grid-cols-2\">\n <div class=\"md:col-span-2\">\n <span class=\"block text-sm font-medium text-gray-700 mb-2\" id=\"webhook-static-type\">Credential type</span>\n <div class=\"flex flex-wrap gap-2\" role=\"radiogroup\" aria-labelledby=\"webhook-static-type\">\n @for (option of staticAuthOptions; track option.type) {\n <label\n class=\"flex cursor-pointer items-center gap-2 rounded-md border bg-white px-3 py-2 text-sm\"\n [class.border-primary-500]=\"formData.authType === option.type\"\n [class.border-gray-300]=\"formData.authType !== option.type\"\n >\n <input\n type=\"radio\"\n name=\"staticAuthType\"\n [checked]=\"formData.authType === option.type\"\n (change)=\"formData.authType = option.type\"\n class=\"h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n {{ option.label }}\n </label>\n }\n </div>\n @for (option of staticAuthOptions; track option.type) {\n @if (formData.authType === option.type) {\n <p class=\"mt-1 font-mono text-xs text-gray-500\">{{ option.hint }}</p>\n }\n }\n </div>\n\n @if (formData.authType === AuthType.BasicAuth) {\n <ui-input\n label=\"Username\"\n inputId=\"webhook-basic-username\"\n [placeholder]=\"credentialStored() ? 'Leave blank to keep the current one' : ''\"\n [(ngModel)]=\"formData.basicUsername\"\n name=\"basicUsername\"\n />\n <ui-input\n label=\"Password\"\n inputId=\"webhook-basic-password\"\n type=\"password\"\n [placeholder]=\"credentialStored() ? 'Leave blank to keep the current one' : ''\"\n [(ngModel)]=\"formData.basicPassword\"\n name=\"basicPassword\"\n />\n } @else {\n <div class=\"md:col-span-2\">\n <ui-input\n [label]=\"formData.authType === AuthType.BearerToken ? 'Token' : 'API key'\"\n inputId=\"webhook-credential\"\n type=\"password\"\n [placeholder]=\"credentialStored() ? 'Leave blank to keep the current one' : ''\"\n [(ngModel)]=\"formData.credential\"\n name=\"credential\"\n />\n </div>\n }\n <div class=\"md:col-span-2\">\n @if (fieldError('credential'); as err) {\n <p class=\"text-sm text-red-600\">{{ err }}</p>\n } @else {\n <p class=\"text-xs text-gray-500\">Stored encrypted and never shown again.</p>\n }\n </div>\n </div>\n @if (!isNew()) {\n <div class=\"flex flex-col gap-2 border-t border-gray-200 pt-3 sm:flex-row sm:items-center sm:justify-between\">\n <p class=\"text-xs text-gray-500\">The HMAC signing secret is also sent with each delivery.</p>\n <ui-button variant=\"secondary\" size=\"sm\" (clicked)=\"showRotateConfirm.set(true)\">\n <ui-icon name=\"key\" class=\"h-4 w-4 mr-1.5\" />\n Rotate secret\n </ui-button>\n </div>\n }\n }\n }\n @if (leavingIssuerSigned()) {\n <p class=\"text-xs text-amber-700\">After saving, rotate the secret to get a signing secret for the receiver.</p>\n }\n </div>\n </ui-card>\n </section>\n\n <!-- 4. Owning client -->\n <section id=\"webhook-client\" class=\"scroll-mt-4\">\n <ui-card\n title=\"Owning client\"\n subtitle=\"The client this endpoint belongs to. Events this client causes itself (for example comments it writes through the Service API) are not sent back to it. With issuer-signed delivery, the token names this client as its subject.\"\n >\n <div class=\"space-y-4\">\n <div class=\"max-w-xl\">\n <label for=\"webhook-client-id\" class=\"block text-sm font-medium text-gray-700 mb-1\">Client</label>\n <select\n id=\"webhook-client-id\"\n name=\"clientId\"\n [(ngModel)]=\"formData.clientId\"\n class=\"w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n >\n <option value=\"\">None</option>\n @for (client of clients(); track client.clientId) {\n <option [value]=\"client.clientId\">{{ clientLabel(client.clientId) }}</option>\n }\n </select>\n </div>\n\n @if (formData.clientId) {\n <div class=\"flex items-start gap-3\">\n <input\n type=\"checkbox\"\n id=\"webhook-skip-own\"\n name=\"skipOwnClientEvents\"\n [(ngModel)]=\"formData.skipOwnClientEvents\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <label for=\"webhook-skip-own\" class=\"text-sm text-gray-700\">\n Skip own client events\n <span class=\"block text-xs text-gray-500\">\n Don't send changes this client made itself. This stops the receiver being notified about its own updates, which can\n otherwise loop.\n </span>\n </label>\n </div>\n }\n </div>\n </ui-card>\n </section>\n\n <!-- 5. Sensitive data -->\n <section id=\"webhook-sensitive\" class=\"scroll-mt-4\">\n <ui-card title=\"Sensitive data\" subtitle=\"Confidential fields of restricted events are withheld unless you authorise this endpoint.\">\n @if (sensitiveEventsOf(formData.eventTypes).length > 0) {\n <div class=\"rounded-md border border-amber-300 bg-amber-50 p-3\">\n <div class=\"flex items-start gap-3\">\n <input\n type=\"checkbox\"\n id=\"webhook-authorize-sensitive\"\n name=\"authorizeSensitiveData\"\n [checked]=\"formData.authorizeSensitiveData\"\n [disabled]=\"!canAuthorizeSensitiveData()\"\n (change)=\"onAuthorizeSensitiveDataChange($any($event.target).checked)\"\n class=\"mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500\"\n />\n <label for=\"webhook-authorize-sensitive\" class=\"text-sm text-gray-800\">\n Send sensitive data to this endpoint\n <span class=\"block text-xs text-gray-600\">\n Without this, the confidential fields of restricted events are withheld from deliveries and listed in\n <code>redacted_fields</code>.\n @if (!canAuthorizeSensitiveData()) {\n You need the permission each restricted event declares to authorise this.\n }\n </span>\n @if (endpoint()?.sensitiveDataAuthorizedAt) {\n <span class=\"block text-xs text-gray-600\">{{ authorizationTitle(endpoint()!) }}</span>\n }\n </label>\n </div>\n <ul class=\"mt-2 ml-7 list-disc text-xs text-gray-600\">\n @for (event of sensitiveEventsOf(formData.eventTypes); track event.eventType) {\n <li>{{ event.displayName }}</li>\n }\n </ul>\n </div>\n } @else {\n <p class=\"text-sm text-gray-500\">None of the selected events carry sensitive fields.</p>\n }\n </ui-card>\n </section>\n\n <!-- 6. Headers and API version -->\n <section id=\"webhook-advanced\" class=\"scroll-mt-4\">\n <ui-card title=\"Headers and version\" subtitle=\"Extra headers sent with every delivery, and the payload format.\">\n <div class=\"space-y-4\">\n <div>\n <span class=\"block text-sm font-medium text-gray-700 mb-2\">Custom headers</span>\n @if (formData.headers.length) {\n <div class=\"space-y-2\">\n @for (row of formData.headers; track $index; let i = $index) {\n <div class=\"grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-2\">\n <input\n type=\"text\"\n [(ngModel)]=\"row.name\"\n [name]=\"'headerName' + i\"\n placeholder=\"X-Header-Name\"\n [attr.aria-label]=\"'Header ' + (i + 1) + ' name'\"\n class=\"w-full rounded-md border-gray-300 shadow-sm font-mono focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n />\n <input\n type=\"text\"\n [(ngModel)]=\"row.value\"\n [name]=\"'headerValue' + i\"\n placeholder=\"Value\"\n [attr.aria-label]=\"'Header ' + (i + 1) + ' value'\"\n class=\"w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm\"\n />\n <button\n type=\"button\"\n (click)=\"removeHeader(i)\"\n class=\"rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600\"\n [attr.aria-label]=\"'Remove header ' + (i + 1)\"\n >\n <ui-icon name=\"trash\" class=\"h-4 w-4\" />\n </button>\n </div>\n }\n </div>\n } @else {\n <p class=\"text-sm text-gray-500\">No custom headers.</p>\n }\n @if (fieldError('headers'); as err) {\n <p class=\"mt-2 text-sm text-red-600\">{{ err }}</p>\n }\n <button type=\"button\" (click)=\"addHeader()\" class=\"mt-2 inline-flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700\">\n <ui-icon name=\"plus\" class=\"h-4 w-4\" />\n Add header\n </button>\n <p class=\"mt-1 text-xs text-gray-500\">Don't put secrets here; header values are stored and shown as plain text.</p>\n </div>\n <div>\n <span class=\"block text-sm font-medium text-gray-700\">API version</span>\n <p class=\"mt-1 font-mono text-sm text-gray-800\">{{ endpoint()?.apiVersion || defaultApiVersion }}</p>\n <p class=\"text-xs text-gray-500\">The payload format deliveries use.</p>\n </div>\n </div>\n </ui-card>\n </section>\n\n <!-- Save bar -->\n <div class=\"sticky bottom-0 z-10 -mx-4 flex justify-end gap-2 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur sm:mx-0 sm:rounded-lg sm:border sm:shadow-sm\">\n <ui-button variant=\"secondary\" (clicked)=\"onCancel()\">Cancel</ui-button>\n <ui-button type=\"submit\" [loading]=\"saving()\">{{ isNew() ? 'Create webhook' : 'Save changes' }}</ui-button>\n </div>\n </div>\n </form>\n }\n\n @if (activeTab() === 'deliveries' && endpoint(); as ep) {\n <div class=\"space-y-6\">\n <div class=\"grid gap-4 sm:grid-cols-3\">\n <ui-card>\n <p class=\"text-sm text-gray-500\">Total deliveries</p>\n <p class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ ep.stats?.totalDeliveries || 0 }}</p>\n </ui-card>\n <ui-card>\n <p class=\"text-sm text-gray-500\">Succeeded</p>\n <p class=\"mt-1 text-2xl font-semibold text-green-600\">{{ ep.stats?.successfulDeliveries || 0 }}</p>\n <p class=\"text-xs text-gray-500\">Last: {{ formatDate(ep.stats?.lastSuccessAt) }}</p>\n </ui-card>\n <ui-card>\n <p class=\"text-sm text-gray-500\">Failed</p>\n <p class=\"mt-1 text-2xl font-semibold text-red-600\">{{ ep.stats?.failedDeliveries || 0 }}</p>\n <p class=\"text-xs text-gray-500\">Last: {{ formatDate(ep.stats?.lastFailureAt) }}</p>\n </ui-card>\n </div>\n\n <ui-card [padding]=\"false\">\n <div class=\"flex items-center justify-between border-b border-gray-200 px-4 py-3 sm:px-6\">\n <h2 class=\"text-lg font-medium text-gray-900\">Delivery history</h2>\n <ui-button variant=\"secondary\" size=\"sm\" [loading]=\"deliveriesLoading()\" (clicked)=\"loadDeliveries()\">\n <ui-icon name=\"arrow-path\" class=\"h-4 w-4 mr-1.5\" />\n Refresh\n </ui-button>\n </div>\n @if (deliveriesLoading() && !deliveriesLoaded()) {\n <div class=\"py-8 text-center text-gray-500\">Loading...</div>\n } @else if (deliveries().length > 0) {\n <div class=\"overflow-x-auto\">\n <table class=\"min-w-full divide-y divide-gray-200\">\n <thead class=\"bg-gray-50\">\n <tr>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Event</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Status</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">HTTP</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Attempts</th>\n <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium uppercase text-gray-500\">Time</th>\n <th scope=\"col\" class=\"px-4 py-2\"><span class=\"sr-only\">Actions</span></th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-200 bg-white\">\n @for (delivery of deliveries(); track delivery.id) {\n <tr>\n <td class=\"px-4 py-2 font-mono text-sm\">{{ delivery.eventType }}</td>\n <td class=\"px-4 py-2\">\n <ui-badge [variant]=\"getStatusVariant(delivery.status)\">{{ delivery.status }}</ui-badge>\n @if (delivery.errorMessage) {\n <span class=\"mt-0.5 block max-w-xs truncate text-xs text-red-600\" [title]=\"delivery.errorMessage\">{{ delivery.errorMessage }}</span>\n }\n </td>\n <td class=\"px-4 py-2 text-sm\">{{ delivery.httpStatus || '-' }}</td>\n <td class=\"px-4 py-2 text-sm\">{{ delivery.retryCount + 1 }}</td>\n <td class=\"px-4 py-2 text-sm text-gray-500 whitespace-nowrap\">{{ formatDate(delivery.createdAt) }}</td>\n <td class=\"px-4 py-2 text-right\">\n @if (canRetry(delivery.status)) {\n <button\n type=\"button\"\n (click)=\"retryDelivery(delivery)\"\n [disabled]=\"retryingId() === delivery.id\"\n class=\"rounded p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50\"\n aria-label=\"Retry delivery\"\n title=\"Retry\"\n >\n <ui-icon name=\"refresh\" class=\"h-4 w-4\" />\n </button>\n }\n </td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n } @else {\n <p class=\"py-8 text-center text-gray-500\">No deliveries yet. Send a test event to try the endpoint.</p>\n }\n </ui-card>\n </div>\n }\n </div>\n\n <!-- Dialogs -->\n <ui-confirm-dialog\n [open]=\"showSensitiveDataConfirm()\"\n title=\"Send sensitive data outside the platform?\"\n [message]=\"sensitiveDataConfirmMessage()\"\n confirmText=\"Authorise\"\n cancelText=\"Cancel\"\n variant=\"danger\"\n [closeOnBackdrop]=\"false\"\n (confirmed)=\"confirmSensitiveData()\"\n (cancelled)=\"cancelSensitiveData()\"\n />\n\n <ui-confirm-dialog\n [open]=\"showDeleteConfirm()\"\n title=\"Delete webhook\"\n [message]=\"'Delete the webhook \\'' + (endpoint()?.name || '') + '\\'? Deliveries stop immediately and this cannot be undone.'\"\n confirmText=\"Delete\"\n cancelText=\"Cancel\"\n variant=\"danger\"\n [loading]=\"deleting()\"\n [closeOnBackdrop]=\"false\"\n (confirmed)=\"confirmDelete()\"\n (cancelled)=\"showDeleteConfirm.set(false)\"\n />\n\n <ui-confirm-dialog\n [open]=\"showRotateConfirm()\"\n title=\"Rotate signing secret\"\n [message]=\"'Rotate the secret for \\'' + (endpoint()?.name || '') + '\\'? The current secret stops working immediately, so update the receiver with the new one.'\"\n confirmText=\"Rotate secret\"\n cancelText=\"Cancel\"\n variant=\"warning\"\n [loading]=\"rotating()\"\n [closeOnBackdrop]=\"false\"\n (confirmed)=\"confirmRotateSecret()\"\n (cancelled)=\"showRotateConfirm.set(false)\"\n />\n\n <ui-modal [open]=\"showSecretModal()\" title=\"Signing secret\" [closeOnBackdrop]=\"false\" (closed)=\"closeSecretModal()\">\n <div class=\"space-y-4\">\n <p class=\"text-sm text-amber-700\">This is the only time this secret is shown. Copy it now.</p>\n <div class=\"flex items-center gap-2\">\n <code class=\"flex-1 break-all rounded bg-gray-100 px-3 py-2 font-mono text-sm\">{{ newSecret() }}</code>\n <ui-button variant=\"secondary\" (clicked)=\"copySecret()\" [attr.aria-label]=\"'Copy secret'\">\n @if (secretCopied()) {\n <ui-icon name=\"check-circle\" class=\"h-5 w-5 text-green-500\" />\n } @else {\n <ui-icon name=\"document-duplicate\" class=\"h-5 w-5\" />\n }\n </ui-button>\n </div>\n <p class=\"text-xs text-gray-500\">\n Use this secret to verify webhook signatures. The signature is sent in the X-Webhook-Signature header as\n sha256=&lt;hex-signature&gt;.\n </p>\n <div class=\"flex justify-end\">\n <ui-button (clicked)=\"closeSecretModal()\">Done</ui-button>\n </div>\n </div>\n </ui-modal>\n}\n" }]
725
+ }] });
726
+
727
+ export { WebhookDetailPageComponent, WebhookSecretHandoff, validateWebhookForm };
728
+ //# sourceMappingURL=syndew-oluso-admin-ui-webhook-detail-page.component-Bh5dZwt-.mjs.map