@hed-hog/core 0.0.354 → 0.0.356

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 (167) hide show
  1. package/dist/ai/ai.service.d.ts +3 -12
  2. package/dist/ai/ai.service.d.ts.map +1 -1
  3. package/dist/ai/ai.service.js +34 -125
  4. package/dist/ai/ai.service.js.map +1 -1
  5. package/dist/core.module.d.ts.map +1 -1
  6. package/dist/core.module.js +3 -3
  7. package/dist/core.module.js.map +1 -1
  8. package/dist/file/file.service.d.ts +1 -1
  9. package/dist/file/file.service.d.ts.map +1 -1
  10. package/dist/file/file.service.js +50 -64
  11. package/dist/file/file.service.js.map +1 -1
  12. package/dist/file/provider/provider.enum.d.ts +2 -1
  13. package/dist/file/provider/provider.enum.d.ts.map +1 -1
  14. package/dist/file/provider/provider.enum.js +1 -0
  15. package/dist/file/provider/provider.enum.js.map +1 -1
  16. package/dist/file/provider/provider.factory.d.ts.map +1 -1
  17. package/dist/file/provider/provider.factory.js +3 -0
  18. package/dist/file/provider/provider.factory.js.map +1 -1
  19. package/dist/file/provider/spaces.provider.d.ts +27 -0
  20. package/dist/file/provider/spaces.provider.d.ts.map +1 -0
  21. package/dist/file/provider/spaces.provider.js +218 -0
  22. package/dist/file/provider/spaces.provider.js.map +1 -0
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/integration-profile/dto/create.dto.d.ts +9 -0
  28. package/dist/integration-profile/dto/create.dto.d.ts.map +1 -0
  29. package/dist/integration-profile/dto/create.dto.js +54 -0
  30. package/dist/integration-profile/dto/create.dto.js.map +1 -0
  31. package/dist/integration-profile/dto/export.dto.d.ts +5 -0
  32. package/dist/integration-profile/dto/export.dto.d.ts.map +1 -0
  33. package/dist/integration-profile/dto/export.dto.js +29 -0
  34. package/dist/integration-profile/dto/export.dto.js.map +1 -0
  35. package/dist/integration-profile/dto/import-confirm.dto.d.ts +17 -0
  36. package/dist/integration-profile/dto/import-confirm.dto.d.ts.map +1 -0
  37. package/dist/integration-profile/dto/import-confirm.dto.js +77 -0
  38. package/dist/integration-profile/dto/import-confirm.dto.js.map +1 -0
  39. package/dist/integration-profile/dto/update.dto.d.ts +6 -0
  40. package/dist/integration-profile/dto/update.dto.d.ts.map +1 -0
  41. package/dist/integration-profile/dto/update.dto.js +9 -0
  42. package/dist/integration-profile/dto/update.dto.js.map +1 -0
  43. package/dist/integration-profile/integration-profile.controller.d.ts +142 -0
  44. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -0
  45. package/dist/integration-profile/integration-profile.controller.js +165 -0
  46. package/dist/integration-profile/integration-profile.controller.js.map +1 -0
  47. package/dist/integration-profile/integration-profile.module.d.ts +3 -0
  48. package/dist/integration-profile/integration-profile.module.d.ts.map +1 -0
  49. package/dist/integration-profile/integration-profile.module.js +29 -0
  50. package/dist/integration-profile/integration-profile.module.js.map +1 -0
  51. package/dist/integration-profile/integration-profile.service.d.ts +160 -0
  52. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -0
  53. package/dist/integration-profile/integration-profile.service.js +606 -0
  54. package/dist/integration-profile/integration-profile.service.js.map +1 -0
  55. package/dist/integration-profile/integration-profile.utils.d.ts +24 -0
  56. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -0
  57. package/dist/integration-profile/integration-profile.utils.js +147 -0
  58. package/dist/integration-profile/integration-profile.utils.js.map +1 -0
  59. package/dist/mail/mail.module.d.ts.map +1 -1
  60. package/dist/mail/mail.module.js +27 -19
  61. package/dist/mail/mail.module.js.map +1 -1
  62. package/dist/mail/mail.service.d.ts +3 -2
  63. package/dist/mail/mail.service.d.ts.map +1 -1
  64. package/dist/mail/mail.service.js +25 -47
  65. package/dist/mail/mail.service.js.map +1 -1
  66. package/dist/mcp/mcp.module.js +2 -2
  67. package/dist/mcp/mcp.module.js.map +1 -1
  68. package/dist/mcp/tools/core-mail-profile.mcp-tools.d.ts +3 -15
  69. package/dist/mcp/tools/core-mail-profile.mcp-tools.d.ts.map +1 -1
  70. package/dist/mcp/tools/core-mail-profile.mcp-tools.js +12 -94
  71. package/dist/mcp/tools/core-mail-profile.mcp-tools.js.map +1 -1
  72. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  73. package/dist/mcp-chat/mcp-chat.service.js +17 -16
  74. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  75. package/dist/setting/setting.service.d.ts.map +1 -1
  76. package/dist/setting/setting.service.js +0 -2
  77. package/dist/setting/setting.service.js.map +1 -1
  78. package/dist/webhook-integration/event-webhook.controller.d.ts +1 -0
  79. package/dist/webhook-integration/event-webhook.controller.d.ts.map +1 -1
  80. package/dist/webhook-integration/event-webhook.controller.js +10 -0
  81. package/dist/webhook-integration/event-webhook.controller.js.map +1 -1
  82. package/dist/webhook-integration/event-webhook.service.d.ts +2 -0
  83. package/dist/webhook-integration/event-webhook.service.d.ts.map +1 -1
  84. package/dist/webhook-integration/event-webhook.service.js +30 -0
  85. package/dist/webhook-integration/event-webhook.service.js.map +1 -1
  86. package/dist/webhook-integration/webhook-integration.controller.d.ts +1 -0
  87. package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -1
  88. package/dist/webhook-integration/webhook-integration.controller.js +10 -0
  89. package/dist/webhook-integration/webhook-integration.controller.js.map +1 -1
  90. package/dist/webhook-integration/webhook-integration.service.d.ts +2 -1
  91. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  92. package/dist/webhook-integration/webhook-integration.service.js +62 -44
  93. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  94. package/dist/whatsapp-profile/dto/create.dto.d.ts +11 -0
  95. package/dist/whatsapp-profile/dto/create.dto.d.ts.map +1 -0
  96. package/dist/whatsapp-profile/dto/create.dto.js +55 -0
  97. package/dist/whatsapp-profile/dto/create.dto.js.map +1 -0
  98. package/dist/whatsapp-profile/dto/update.dto.d.ts +6 -0
  99. package/dist/whatsapp-profile/dto/update.dto.d.ts.map +1 -0
  100. package/dist/whatsapp-profile/dto/update.dto.js +9 -0
  101. package/dist/whatsapp-profile/dto/update.dto.js.map +1 -0
  102. package/dist/whatsapp-profile/whatsapp-profile.controller.d.ts +54 -0
  103. package/dist/whatsapp-profile/whatsapp-profile.controller.d.ts.map +1 -0
  104. package/dist/whatsapp-profile/whatsapp-profile.controller.js +85 -0
  105. package/dist/whatsapp-profile/whatsapp-profile.controller.js.map +1 -0
  106. package/dist/whatsapp-profile/whatsapp-profile.module.d.ts +3 -0
  107. package/dist/whatsapp-profile/whatsapp-profile.module.d.ts.map +1 -0
  108. package/dist/whatsapp-profile/whatsapp-profile.module.js +26 -0
  109. package/dist/whatsapp-profile/whatsapp-profile.module.js.map +1 -0
  110. package/dist/whatsapp-profile/whatsapp-profile.service.d.ts +63 -0
  111. package/dist/whatsapp-profile/whatsapp-profile.service.d.ts.map +1 -0
  112. package/dist/whatsapp-profile/whatsapp-profile.service.js +138 -0
  113. package/dist/whatsapp-profile/whatsapp-profile.service.js.map +1 -0
  114. package/hedhog/data/file_provider.yaml +9 -1
  115. package/hedhog/data/integration_provider.yaml +132 -0
  116. package/hedhog/data/integration_type.yaml +29 -0
  117. package/hedhog/data/menu.yaml +15 -14
  118. package/hedhog/data/role.yaml +8 -0
  119. package/hedhog/data/route.yaml +66 -15
  120. package/hedhog/data/setting_group.yaml +30 -191
  121. package/hedhog/data/setting_subgroup.yaml +5 -13
  122. package/hedhog/data/whatsapp_profile.yaml +6 -0
  123. package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +25 -20
  124. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +579 -204
  125. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +1662 -0
  126. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +233 -108
  127. package/hedhog/frontend/app/mail/profiles/page.tsx.ejs +3 -822
  128. package/hedhog/frontend/app/users/permissions.tsx.ejs +79 -2
  129. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +7 -2
  130. package/hedhog/frontend/messages/en.json +172 -0
  131. package/hedhog/frontend/messages/pt.json +172 -0
  132. package/hedhog/table/integration_profile.yaml +34 -0
  133. package/hedhog/table/integration_provider.yaml +23 -0
  134. package/hedhog/table/integration_type.yaml +19 -0
  135. package/hedhog/table/webhook_action.yaml +85 -85
  136. package/hedhog/table/whatsapp_profile.yaml +26 -0
  137. package/package.json +2 -2
  138. package/src/ai/ai.service.ts +35 -153
  139. package/src/core.module.ts +3 -3
  140. package/src/file/file.service.ts +60 -76
  141. package/src/file/provider/provider.enum.ts +1 -0
  142. package/src/file/provider/provider.factory.ts +3 -0
  143. package/src/file/provider/spaces.provider.ts +261 -0
  144. package/src/index.ts +6 -3
  145. package/src/integration-profile/dto/create.dto.ts +44 -0
  146. package/src/integration-profile/dto/export.dto.ts +15 -0
  147. package/src/integration-profile/dto/import-confirm.dto.ts +61 -0
  148. package/src/integration-profile/dto/update.dto.ts +4 -0
  149. package/src/integration-profile/integration-profile.controller.ts +129 -0
  150. package/src/integration-profile/integration-profile.module.ts +16 -0
  151. package/src/integration-profile/integration-profile.service.ts +788 -0
  152. package/src/integration-profile/integration-profile.utils.ts +156 -0
  153. package/src/mail/mail.module.ts +27 -18
  154. package/src/mail/mail.service.ts +31 -60
  155. package/src/mcp/mcp.module.ts +2 -2
  156. package/src/mcp/tools/core-mail-profile.mcp-tools.ts +10 -69
  157. package/src/mcp-chat/mcp-chat.service.ts +23 -20
  158. package/src/setting/setting.service.ts +0 -2
  159. package/src/webhook-integration/event-webhook.controller.ts +55 -50
  160. package/src/webhook-integration/event-webhook.service.ts +422 -384
  161. package/src/webhook-integration/webhook-integration.controller.ts +5 -0
  162. package/src/webhook-integration/webhook-integration.service.ts +74 -46
  163. package/src/whatsapp-profile/dto/create.dto.ts +36 -0
  164. package/src/whatsapp-profile/dto/update.dto.ts +4 -0
  165. package/src/whatsapp-profile/whatsapp-profile.controller.ts +51 -0
  166. package/src/whatsapp-profile/whatsapp-profile.module.ts +13 -0
  167. package/src/whatsapp-profile/whatsapp-profile.service.ts +160 -0
@@ -0,0 +1,1662 @@
1
+ 'use client';
2
+
3
+ import type React from 'react';
4
+
5
+ import {
6
+ EmptyState,
7
+ Page,
8
+ PageHeader,
9
+ PaginationFooter,
10
+ SearchBar,
11
+ } from '@/components/entity-list';
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ } from '@/components/ui/alert-dialog';
22
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
23
+ import { Badge } from '@/components/ui/badge';
24
+ import { Button } from '@/components/ui/button';
25
+ import { Checkbox } from '@/components/ui/checkbox';
26
+ import {
27
+ Dialog,
28
+ DialogContent,
29
+ DialogDescription,
30
+ DialogFooter,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ } from '@/components/ui/dialog';
34
+ import { Input } from '@/components/ui/input';
35
+ import { IntegrationLogo } from '@/components/ui/integration-logo';
36
+ import { Label } from '@/components/ui/label';
37
+ import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
38
+ import {
39
+ Select,
40
+ SelectContent,
41
+ SelectItem,
42
+ SelectTrigger,
43
+ SelectValue,
44
+ } from '@/components/ui/select';
45
+ import {
46
+ Sheet,
47
+ SheetDescription,
48
+ SheetHeader,
49
+ SheetTitle,
50
+ } from '@/components/ui/sheet';
51
+ import { Switch } from '@/components/ui/switch';
52
+ import {
53
+ Table,
54
+ TableBody,
55
+ TableCell,
56
+ TableHead,
57
+ TableHeader,
58
+ TableRow,
59
+ } from '@/components/ui/table';
60
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
61
+ import { usePagination } from '@/hooks/use-pagination';
62
+ import { formatDate } from '@/lib/format-date';
63
+ import { useApp } from '@hed-hog/next-app-provider';
64
+ import { Eye, EyeOff, Pencil, PlugZap, Trash2 } from 'lucide-react';
65
+ import { useTranslations } from 'next-intl';
66
+ import { useEffect, useRef, useState } from 'react';
67
+ import { toast } from 'sonner';
68
+
69
+ // ─── Types ────────────────────────────────────────────────────────────────────
70
+
71
+ type LocaleEntry = { name: string; locale: { code: string } };
72
+
73
+ type IntegrationType = {
74
+ id: number;
75
+ slug: string;
76
+ icon: string | null;
77
+ integration_type_locale: LocaleEntry[];
78
+ };
79
+
80
+ type IntegrationProvider = {
81
+ id: number;
82
+ slug: string;
83
+ type_id: number;
84
+ integration_provider_locale: LocaleEntry[];
85
+ };
86
+
87
+ type IntegrationProfile = {
88
+ id: number;
89
+ slug: string;
90
+ name: string;
91
+ type_id: number;
92
+ provider_id: number;
93
+ config: Record<string, unknown> | null;
94
+ is_active: boolean;
95
+ uses_count: number;
96
+ created_at: string;
97
+ updated_at: string;
98
+ integration_type?: IntegrationType;
99
+ integration_provider?: IntegrationProvider;
100
+ };
101
+
102
+ type ProfileForm = {
103
+ name: string;
104
+ slug: string;
105
+ type_id: string;
106
+ provider_id: string;
107
+ config: Record<string, string | boolean | number>;
108
+ is_active: boolean;
109
+ };
110
+
111
+ type FieldDef = {
112
+ key: string;
113
+ labelKey: string;
114
+ type: 'text' | 'password' | 'number' | 'boolean' | 'email' | 'url';
115
+ required: boolean;
116
+ placeholder?: string;
117
+ colSpan?: boolean;
118
+ };
119
+
120
+ type ImportValidatedProfile = {
121
+ index: number;
122
+ slug: string;
123
+ name: string;
124
+ type_slug: string;
125
+ provider_slug: string;
126
+ config: Record<string, unknown> | null;
127
+ is_active: boolean;
128
+ has_conflict: boolean;
129
+ };
130
+
131
+ type ImportValidationResponse = {
132
+ format: {
133
+ type: string;
134
+ version: number;
135
+ generated_at: string;
136
+ include_secrets: boolean;
137
+ };
138
+ total_profiles: number;
139
+ valid_profiles: number;
140
+ invalid_profiles: number;
141
+ conflict_profiles: number;
142
+ importable_profiles: number;
143
+ conflict_slugs: string[];
144
+ profiles: ImportValidatedProfile[];
145
+ invalid_items: Array<{
146
+ index: number;
147
+ slug?: string;
148
+ errors: string[];
149
+ }>;
150
+ };
151
+
152
+ // ─── Provider field definitions ───────────────────────────────────────────────
153
+
154
+ const PROVIDER_FIELDS: Record<string, FieldDef[]> = {
155
+ smtp: [
156
+ {
157
+ key: 'host',
158
+ labelKey: 'fieldHost',
159
+ type: 'text',
160
+ required: true,
161
+ placeholder: 'smtp.yourdomain.com',
162
+ },
163
+ {
164
+ key: 'port',
165
+ labelKey: 'fieldPort',
166
+ type: 'number',
167
+ required: true,
168
+ placeholder: '587',
169
+ },
170
+ {
171
+ key: 'username',
172
+ labelKey: 'fieldUsername',
173
+ type: 'text',
174
+ required: true,
175
+ },
176
+ {
177
+ key: 'password',
178
+ labelKey: 'fieldPassword',
179
+ type: 'password',
180
+ required: true,
181
+ },
182
+ {
183
+ key: 'from_email',
184
+ labelKey: 'fieldFromEmail',
185
+ type: 'email',
186
+ required: true,
187
+ },
188
+ {
189
+ key: 'from_name',
190
+ labelKey: 'fieldFromName',
191
+ type: 'text',
192
+ required: false,
193
+ },
194
+ {
195
+ key: 'reply_to_email',
196
+ labelKey: 'fieldReplyTo',
197
+ type: 'email',
198
+ required: false,
199
+ },
200
+ {
201
+ key: 'secure',
202
+ labelKey: 'fieldSecure',
203
+ type: 'boolean',
204
+ required: false,
205
+ colSpan: true,
206
+ },
207
+ ],
208
+ gmail: [
209
+ {
210
+ key: 'client_id',
211
+ labelKey: 'fieldClientId',
212
+ type: 'text',
213
+ required: true,
214
+ },
215
+ {
216
+ key: 'client_secret',
217
+ labelKey: 'fieldClientSecret',
218
+ type: 'password',
219
+ required: true,
220
+ },
221
+ {
222
+ key: 'refresh_token',
223
+ labelKey: 'fieldRefreshToken',
224
+ type: 'password',
225
+ required: true,
226
+ colSpan: true,
227
+ },
228
+ {
229
+ key: 'from_email',
230
+ labelKey: 'fieldFromEmail',
231
+ type: 'email',
232
+ required: true,
233
+ },
234
+ {
235
+ key: 'from_name',
236
+ labelKey: 'fieldFromName',
237
+ type: 'text',
238
+ required: false,
239
+ },
240
+ ],
241
+ ses: [
242
+ {
243
+ key: 'access_key_id',
244
+ labelKey: 'fieldAccessKeyId',
245
+ type: 'text',
246
+ required: true,
247
+ },
248
+ {
249
+ key: 'secret_access_key',
250
+ labelKey: 'fieldSecretAccessKey',
251
+ type: 'password',
252
+ required: true,
253
+ },
254
+ {
255
+ key: 'region',
256
+ labelKey: 'fieldRegion',
257
+ type: 'text',
258
+ required: true,
259
+ placeholder: 'us-east-1',
260
+ },
261
+ {
262
+ key: 'from_email',
263
+ labelKey: 'fieldFromEmail',
264
+ type: 'email',
265
+ required: true,
266
+ },
267
+ {
268
+ key: 'from_name',
269
+ labelKey: 'fieldFromName',
270
+ type: 'text',
271
+ required: false,
272
+ },
273
+ ],
274
+ 'evolution-api': [
275
+ {
276
+ key: 'host',
277
+ labelKey: 'fieldHost',
278
+ type: 'url',
279
+ required: true,
280
+ placeholder: 'https://evolution.yourdomain.com',
281
+ colSpan: true,
282
+ },
283
+ { key: 'token', labelKey: 'fieldToken', type: 'password', required: true },
284
+ {
285
+ key: 'instance_name',
286
+ labelKey: 'fieldInstanceName',
287
+ type: 'text',
288
+ required: true,
289
+ },
290
+ ],
291
+ 'whatsapp-official': [
292
+ {
293
+ key: 'phone_number_id',
294
+ labelKey: 'fieldPhoneNumberId',
295
+ type: 'text',
296
+ required: true,
297
+ },
298
+ {
299
+ key: 'access_token',
300
+ labelKey: 'fieldAccessToken',
301
+ type: 'password',
302
+ required: true,
303
+ },
304
+ {
305
+ key: 'business_id',
306
+ labelKey: 'fieldBusinessId',
307
+ type: 'text',
308
+ required: false,
309
+ },
310
+ ],
311
+ local: [
312
+ {
313
+ key: 'base_path',
314
+ labelKey: 'fieldBasePath',
315
+ type: 'text',
316
+ required: true,
317
+ placeholder: '/var/uploads',
318
+ colSpan: true,
319
+ },
320
+ ],
321
+ s3: [
322
+ {
323
+ key: 'access_key_id',
324
+ labelKey: 'fieldAccessKeyId',
325
+ type: 'text',
326
+ required: true,
327
+ },
328
+ {
329
+ key: 'secret_access_key',
330
+ labelKey: 'fieldSecretAccessKey',
331
+ type: 'password',
332
+ required: true,
333
+ },
334
+ {
335
+ key: 'region',
336
+ labelKey: 'fieldRegion',
337
+ type: 'text',
338
+ required: true,
339
+ placeholder: 'us-east-1',
340
+ },
341
+ { key: 'bucket', labelKey: 'fieldBucket', type: 'text', required: true },
342
+ ],
343
+ gcs: [
344
+ {
345
+ key: 'project_id',
346
+ labelKey: 'fieldProjectId',
347
+ type: 'text',
348
+ required: true,
349
+ },
350
+ { key: 'bucket', labelKey: 'fieldBucket', type: 'text', required: true },
351
+ {
352
+ key: 'key_file_json',
353
+ labelKey: 'fieldKeyFileJson',
354
+ type: 'password',
355
+ required: true,
356
+ colSpan: true,
357
+ },
358
+ ],
359
+ 'azure-blob': [
360
+ {
361
+ key: 'account',
362
+ labelKey: 'fieldAccount',
363
+ type: 'text',
364
+ required: true,
365
+ placeholder: 'mystorageaccount',
366
+ },
367
+ {
368
+ key: 'key',
369
+ labelKey: 'fieldAccountKey',
370
+ type: 'password',
371
+ required: true,
372
+ },
373
+ {
374
+ key: 'container',
375
+ labelKey: 'fieldContainer',
376
+ type: 'text',
377
+ required: true,
378
+ },
379
+ ],
380
+ 's3-compatible': [
381
+ {
382
+ key: 'endpoint',
383
+ labelKey: 'fieldEndpoint',
384
+ type: 'url',
385
+ required: true,
386
+ colSpan: true,
387
+ },
388
+ {
389
+ key: 'access_key_id',
390
+ labelKey: 'fieldAccessKeyId',
391
+ type: 'text',
392
+ required: true,
393
+ },
394
+ {
395
+ key: 'secret_access_key',
396
+ labelKey: 'fieldSecretAccessKey',
397
+ type: 'password',
398
+ required: true,
399
+ },
400
+ { key: 'bucket', labelKey: 'fieldBucket', type: 'text', required: true },
401
+ {
402
+ key: 'region',
403
+ labelKey: 'fieldRegion',
404
+ type: 'text',
405
+ required: false,
406
+ placeholder: 'us-east-1',
407
+ },
408
+ ],
409
+ openai: [
410
+ {
411
+ key: 'api_key',
412
+ labelKey: 'fieldApiKey',
413
+ type: 'password',
414
+ required: true,
415
+ colSpan: true,
416
+ },
417
+ {
418
+ key: 'organization',
419
+ labelKey: 'fieldOrganization',
420
+ type: 'text',
421
+ required: false,
422
+ },
423
+ ],
424
+ gemini: [
425
+ {
426
+ key: 'api_key',
427
+ labelKey: 'fieldApiKey',
428
+ type: 'password',
429
+ required: true,
430
+ colSpan: true,
431
+ },
432
+ ],
433
+ claude: [
434
+ {
435
+ key: 'api_key',
436
+ labelKey: 'fieldApiKey',
437
+ type: 'password',
438
+ required: true,
439
+ colSpan: true,
440
+ },
441
+ ],
442
+ 'google-oauth': [
443
+ {
444
+ key: 'client_id',
445
+ labelKey: 'fieldClientId',
446
+ type: 'text',
447
+ required: true,
448
+ },
449
+ {
450
+ key: 'client_secret',
451
+ labelKey: 'fieldClientSecret',
452
+ type: 'password',
453
+ required: true,
454
+ },
455
+ {
456
+ key: 'redirect_uri',
457
+ labelKey: 'fieldRedirectUri',
458
+ type: 'url',
459
+ required: true,
460
+ colSpan: true,
461
+ },
462
+ ],
463
+ 'github-oauth': [
464
+ {
465
+ key: 'client_id',
466
+ labelKey: 'fieldClientId',
467
+ type: 'text',
468
+ required: true,
469
+ },
470
+ {
471
+ key: 'client_secret',
472
+ labelKey: 'fieldClientSecret',
473
+ type: 'password',
474
+ required: true,
475
+ },
476
+ ],
477
+ 'microsoft-oauth': [
478
+ {
479
+ key: 'client_id',
480
+ labelKey: 'fieldClientId',
481
+ type: 'text',
482
+ required: true,
483
+ },
484
+ {
485
+ key: 'client_secret',
486
+ labelKey: 'fieldClientSecret',
487
+ type: 'password',
488
+ required: true,
489
+ },
490
+ {
491
+ key: 'tenant_id',
492
+ labelKey: 'fieldTenantId',
493
+ type: 'text',
494
+ required: true,
495
+ placeholder: 'common',
496
+ },
497
+ {
498
+ key: 'redirect_uri',
499
+ labelKey: 'fieldRedirectUri',
500
+ type: 'url',
501
+ required: true,
502
+ colSpan: true,
503
+ },
504
+ ],
505
+ };
506
+
507
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
508
+
509
+ function getLocaleName(locales: LocaleEntry[], code: string): string {
510
+ return (
511
+ locales.find((l) => l.locale.code === code)?.name ?? locales[0]?.name ?? ''
512
+ );
513
+ }
514
+
515
+ function toSlug(name: string): string {
516
+ return name
517
+ .toLowerCase()
518
+ .normalize('NFD')
519
+ .replace(/[̀-ͯ]/g, '')
520
+ .replace(/[^a-z0-9]+/g, '-')
521
+ .replace(/^-+|-+$/g, '');
522
+ }
523
+
524
+ const defaultForm = (): ProfileForm => ({
525
+ name: '',
526
+ slug: '',
527
+ type_id: '',
528
+ provider_id: '',
529
+ config: {},
530
+ is_active: true,
531
+ });
532
+
533
+ // ─── Tab list component ───────────────────────────────────────────────────────
534
+
535
+ function TypeTab({
536
+ typeId,
537
+ localeCode,
538
+ types,
539
+ providers,
540
+ onEdit,
541
+ onDelete,
542
+ refreshKey,
543
+ t,
544
+ getSettingValue,
545
+ currentLocaleCode,
546
+ selectedProfileIds,
547
+ onToggleProfileSelection,
548
+ onToggleAllVisible,
549
+ }: {
550
+ typeId: number;
551
+ localeCode: string;
552
+ types: IntegrationType[];
553
+ providers: IntegrationProvider[];
554
+ onEdit: (profile: IntegrationProfile) => void;
555
+ onDelete: (id: number) => void;
556
+ refreshKey: number;
557
+ t: ReturnType<typeof useTranslations>;
558
+ getSettingValue: (key: string) => string | undefined;
559
+ currentLocaleCode: string;
560
+ selectedProfileIds: Set<number>;
561
+ onToggleProfileSelection: (id: number, checked: boolean) => void;
562
+ onToggleAllVisible: (ids: number[], checked: boolean) => void;
563
+ }) {
564
+ const {
565
+ items,
566
+ refetch,
567
+ page,
568
+ setPage,
569
+ pageSize,
570
+ setPageSize,
571
+ totalItems,
572
+ search,
573
+ setSearch,
574
+ } = usePagination({ url: `/integration-profile?typeId=${typeId}` });
575
+
576
+ useEffect(() => {
577
+ if (refreshKey > 0) refetch();
578
+ }, [refreshKey]);
579
+
580
+ const profiles = Array.isArray(items) ? (items as IntegrationProfile[]) : [];
581
+ const allVisibleSelected =
582
+ profiles.length > 0 &&
583
+ profiles.every((profile) => selectedProfileIds.has(profile.id));
584
+
585
+ const typeById = new Map(types.map((type) => [type.id, type]));
586
+ const providerById = new Map(
587
+ providers.map((provider) => [provider.id, provider])
588
+ );
589
+
590
+ const toTitle = (value: string) =>
591
+ value.replace(/[-_]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
592
+
593
+ const getResolvedType = (profile: IntegrationProfile) => {
594
+ return profile.integration_type ?? typeById.get(profile.type_id);
595
+ };
596
+
597
+ const getResolvedProvider = (profile: IntegrationProfile) => {
598
+ return (
599
+ profile.integration_provider ?? providerById.get(profile.provider_id)
600
+ );
601
+ };
602
+
603
+ const getProviderName = (profile: IntegrationProfile) => {
604
+ const provider = getResolvedProvider(profile);
605
+ if (!provider) return '';
606
+ return (
607
+ getLocaleName(provider.integration_provider_locale, localeCode) ||
608
+ toTitle(provider.slug)
609
+ );
610
+ };
611
+
612
+ const getProviderSlug = (profile: IntegrationProfile) => {
613
+ return getResolvedProvider(profile)?.slug ?? '';
614
+ };
615
+
616
+ const getConfigurationLabel = (profile: IntegrationProfile) => {
617
+ const providerSlug = getProviderSlug(profile);
618
+ if (providerSlug) return providerSlug.toUpperCase();
619
+
620
+ const typeSlug = getResolvedType(profile)?.slug;
621
+ if (typeSlug === 'email') return 'SMTP';
622
+
623
+ return '—';
624
+ };
625
+
626
+ return (
627
+ <div className="space-y-4">
628
+ <SearchBar
629
+ searchQuery={search}
630
+ onSearchChange={(value) => {
631
+ setSearch(value);
632
+ setPage(1);
633
+ }}
634
+ onSearch={() => setPage(1)}
635
+ placeholder={t('searchPlaceholder')}
636
+ />
637
+
638
+ {profiles.length === 0 ? (
639
+ <EmptyState
640
+ icon={<PlugZap className="h-12 w-12" />}
641
+ title={t('noProfilesFound')}
642
+ description={t('noProfilesHint')}
643
+ />
644
+ ) : (
645
+ <div className="rounded-md border">
646
+ <Table>
647
+ <TableHeader>
648
+ <TableRow>
649
+ <TableHead className="w-10">
650
+ <Checkbox
651
+ checked={allVisibleSelected}
652
+ onCheckedChange={(checked) =>
653
+ onToggleAllVisible(
654
+ profiles.map((profile) => profile.id),
655
+ checked === true
656
+ )
657
+ }
658
+ aria-label={t('selectAllVisible')}
659
+ />
660
+ </TableHead>
661
+ <TableHead>{t('tableName')}</TableHead>
662
+ <TableHead>{t('tableSlug')}</TableHead>
663
+ <TableHead>{t('tableProvider')}</TableHead>
664
+ <TableHead>
665
+ {currentLocaleCode.startsWith('pt')
666
+ ? 'Configuração'
667
+ : 'Configuration'}
668
+ </TableHead>
669
+ <TableHead>{t('tableStatus')}</TableHead>
670
+ <TableHead className="text-right">{t('tableUses')}</TableHead>
671
+ <TableHead>{t('tableUpdated')}</TableHead>
672
+ <TableHead className="text-right">
673
+ {t('tableActions')}
674
+ </TableHead>
675
+ </TableRow>
676
+ </TableHeader>
677
+ <TableBody>
678
+ {profiles.map((profile) => (
679
+ <TableRow
680
+ key={profile.id}
681
+ className="cursor-pointer"
682
+ onDoubleClick={() => onEdit(profile)}
683
+ >
684
+ <TableCell>
685
+ <Checkbox
686
+ checked={selectedProfileIds.has(profile.id)}
687
+ onCheckedChange={(checked) =>
688
+ onToggleProfileSelection(profile.id, checked === true)
689
+ }
690
+ onClick={(event) => event.stopPropagation()}
691
+ aria-label={t('selectProfile')}
692
+ />
693
+ </TableCell>
694
+ <TableCell className="font-medium">{profile.name}</TableCell>
695
+ <TableCell className="font-mono text-sm text-muted-foreground">
696
+ {profile.slug}
697
+ </TableCell>
698
+ <TableCell>
699
+ <Badge variant="secondary" className="gap-1.5">
700
+ <IntegrationLogo
701
+ provider={getProviderSlug(profile)}
702
+ size={14}
703
+ decorative
704
+ />
705
+ {getProviderName(profile)}
706
+ </Badge>
707
+ </TableCell>
708
+ <TableCell>
709
+ <Badge variant="outline">
710
+ {getConfigurationLabel(profile)}
711
+ </Badge>
712
+ </TableCell>
713
+ <TableCell>
714
+ <Badge variant={profile.is_active ? 'default' : 'outline'}>
715
+ {profile.is_active
716
+ ? t('statusActive')
717
+ : t('statusInactive')}
718
+ </Badge>
719
+ </TableCell>
720
+ <TableCell className="text-right">
721
+ {profile.uses_count > 0 ? (
722
+ <Badge variant="secondary">{profile.uses_count}</Badge>
723
+ ) : (
724
+ <span className="text-muted-foreground">—</span>
725
+ )}
726
+ </TableCell>
727
+ <TableCell className="text-muted-foreground">
728
+ {formatDate(
729
+ profile.updated_at,
730
+ getSettingValue,
731
+ currentLocaleCode
732
+ )}
733
+ </TableCell>
734
+ <TableCell className="text-right">
735
+ <div className="flex justify-end gap-2">
736
+ <Button
737
+ variant="ghost"
738
+ size="icon"
739
+ onClick={(e) => {
740
+ e.stopPropagation();
741
+ onEdit(profile);
742
+ }}
743
+ >
744
+ <Pencil className="h-4 w-4" />
745
+ </Button>
746
+ <Button
747
+ variant="ghost"
748
+ size="icon"
749
+ onClick={(e) => {
750
+ e.stopPropagation();
751
+ onDelete(profile.id);
752
+ }}
753
+ >
754
+ <Trash2 className="h-4 w-4 text-destructive" />
755
+ </Button>
756
+ </div>
757
+ </TableCell>
758
+ </TableRow>
759
+ ))}
760
+ </TableBody>
761
+ </Table>
762
+ </div>
763
+ )}
764
+
765
+ <PaginationFooter
766
+ currentPage={page}
767
+ pageSize={pageSize}
768
+ totalItems={totalItems}
769
+ onPageChange={setPage}
770
+ onPageSizeChange={(newSize) => {
771
+ setPageSize(newSize);
772
+ setPage(1);
773
+ }}
774
+ />
775
+ </div>
776
+ );
777
+ }
778
+
779
+ // ─── Main page ────────────────────────────────────────────────────────────────
780
+
781
+ export default function IntegrationProfilesPage() {
782
+ const t = useTranslations('core.IntegrationProfilePage');
783
+ const { request, currentLocaleCode, getSettingValue } = useApp();
784
+
785
+ const [types, setTypes] = useState<IntegrationType[]>([]);
786
+ const [providers, setProviders] = useState<IntegrationProvider[]>([]);
787
+ const [activeTab, setActiveTab] = useState<string>('');
788
+ const [refreshKey, setRefreshKey] = useState(0);
789
+
790
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
791
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
792
+ const [profileToDelete, setProfileToDelete] = useState<number | null>(null);
793
+ const [editingProfile, setEditingProfile] =
794
+ useState<IntegrationProfile | null>(null);
795
+ const [formData, setFormData] = useState<ProfileForm>(defaultForm());
796
+ const [isSubmitting, setIsSubmitting] = useState(false);
797
+ const [visibleFields, setVisibleFields] = useState<Record<string, boolean>>(
798
+ {}
799
+ );
800
+ const [selectedProfileIds, setSelectedProfileIds] = useState<Set<number>>(
801
+ new Set()
802
+ );
803
+ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
804
+ const [exportIncludeSecrets, setExportIncludeSecrets] = useState(false);
805
+ const [pendingExportScope, setPendingExportScope] = useState<
806
+ 'all' | 'selected'
807
+ >('all');
808
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
809
+ const [isImportSubmitting, setIsImportSubmitting] = useState(false);
810
+ const [importPreview, setImportPreview] =
811
+ useState<ImportValidationResponse | null>(null);
812
+ const [conflictActions, setConflictActions] = useState<
813
+ Record<string, 'ignore' | 'replace' | 'rename_auto'>
814
+ >({});
815
+ const fileInputRef = useRef<HTMLInputElement>(null);
816
+ const slugAutoRef = useRef(true);
817
+
818
+ // Providers filtered by the type currently selected in the form
819
+ const formTypeProviders = providers.filter(
820
+ (p) => String(p.type_id) === formData.type_id
821
+ );
822
+ const selectedProvider = formTypeProviders.find(
823
+ (p) => String(p.id) === formData.provider_id
824
+ );
825
+ const providerFields = selectedProvider
826
+ ? (PROVIDER_FIELDS[selectedProvider.slug] ?? [])
827
+ : [];
828
+
829
+ useEffect(() => {
830
+ request<IntegrationType[]>({
831
+ url: '/integration-profile/types',
832
+ method: 'GET',
833
+ })
834
+ .then(({ data }) => {
835
+ const list = Array.isArray(data) ? data : ((data as any).data ?? []);
836
+ setTypes(list);
837
+ if (list.length > 0 && !activeTab) setActiveTab(list[0].slug);
838
+ })
839
+ .catch(() => toast.error(t('loadError')));
840
+
841
+ request<IntegrationProvider[]>({
842
+ url: '/integration-profile/providers',
843
+ method: 'GET',
844
+ })
845
+ .then(({ data }) => {
846
+ const list = Array.isArray(data) ? data : ((data as any).data ?? []);
847
+ setProviders(list);
848
+ })
849
+ .catch(() => {});
850
+ }, []);
851
+
852
+ const handleCreate = () => {
853
+ const activeType = types.find((ty) => ty.slug === activeTab);
854
+ setEditingProfile(null);
855
+ slugAutoRef.current = true;
856
+ setFormData({
857
+ ...defaultForm(),
858
+ type_id: activeType ? String(activeType.id) : '',
859
+ });
860
+ setIsSheetOpen(true);
861
+ };
862
+
863
+ const handleEdit = async (profile: IntegrationProfile) => {
864
+ setEditingProfile(profile);
865
+ slugAutoRef.current = false;
866
+ try {
867
+ const { data } = await request<IntegrationProfile>({
868
+ url: `/integration-profile/${profile.id}`,
869
+ method: 'GET',
870
+ });
871
+ setFormData({
872
+ name: data.name,
873
+ slug: data.slug,
874
+ type_id: String(data.type_id),
875
+ provider_id: String(data.provider_id),
876
+ config:
877
+ (data.config as Record<string, string | boolean | number>) ?? {},
878
+ is_active: data.is_active,
879
+ });
880
+ } catch {
881
+ toast.error(t('loadError'));
882
+ return;
883
+ }
884
+ setIsSheetOpen(true);
885
+ };
886
+
887
+ const handleDelete = (id: number) => {
888
+ setProfileToDelete(id);
889
+ setIsDeleteDialogOpen(true);
890
+ };
891
+
892
+ const handleToggleProfileSelection = (id: number, checked: boolean) => {
893
+ setSelectedProfileIds((prev) => {
894
+ const next = new Set(prev);
895
+ if (checked) next.add(id);
896
+ else next.delete(id);
897
+ return next;
898
+ });
899
+ };
900
+
901
+ const handleToggleAllVisible = (ids: number[], checked: boolean) => {
902
+ setSelectedProfileIds((prev) => {
903
+ const next = new Set(prev);
904
+ ids.forEach((id) => {
905
+ if (checked) next.add(id);
906
+ else next.delete(id);
907
+ });
908
+ return next;
909
+ });
910
+ };
911
+
912
+ const openExportDialog = (scope: 'all' | 'selected') => {
913
+ setPendingExportScope(scope);
914
+ setExportIncludeSecrets(false);
915
+ setIsExportDialogOpen(true);
916
+ };
917
+
918
+ const handleExport = async () => {
919
+ const ids = Array.from(selectedProfileIds);
920
+ if (pendingExportScope === 'selected' && ids.length === 0) {
921
+ toast.error(t('noProfilesSelected'));
922
+ return;
923
+ }
924
+
925
+ const params = new URLSearchParams();
926
+ params.set('include_secrets', exportIncludeSecrets ? 'true' : 'false');
927
+ if (pendingExportScope === 'selected') {
928
+ params.set('ids', ids.join(','));
929
+ }
930
+
931
+ try {
932
+ const response = await request<Blob>({
933
+ url: `/integration-profile/export?${params.toString()}`,
934
+ method: 'GET',
935
+ responseType: 'blob',
936
+ });
937
+
938
+ const blob = new Blob([response.data], {
939
+ type: 'application/octet-stream',
940
+ });
941
+ const url = URL.createObjectURL(blob);
942
+ const anchor = document.createElement('a');
943
+ anchor.href = url;
944
+ anchor.download = `integration-profiles-${new Date().toISOString().split('T')[0]}.hedhog`;
945
+ document.body.appendChild(anchor);
946
+ anchor.click();
947
+ document.body.removeChild(anchor);
948
+ URL.revokeObjectURL(url);
949
+
950
+ setIsExportDialogOpen(false);
951
+ toast.success(t('exportSuccess'));
952
+ } catch {
953
+ toast.error(t('exportError'));
954
+ }
955
+ };
956
+
957
+ const handleImportClick = () => {
958
+ fileInputRef.current?.click();
959
+ };
960
+
961
+ const handleImportFileChange = async (
962
+ event: React.ChangeEvent<HTMLInputElement>
963
+ ) => {
964
+ const file = event.target.files?.[0];
965
+ event.target.value = '';
966
+ if (!file) return;
967
+
968
+ const formData = new FormData();
969
+ formData.append('file', file);
970
+
971
+ try {
972
+ const response = await request<ImportValidationResponse>({
973
+ url: '/integration-profile/import/validate',
974
+ method: 'POST',
975
+ data: formData,
976
+ headers: {
977
+ 'Content-Type': 'multipart/form-data',
978
+ },
979
+ });
980
+
981
+ const initialConflictActions = Object.fromEntries(
982
+ response.data.conflict_slugs.map((slug) => [
983
+ slug,
984
+ 'rename_auto' as const,
985
+ ])
986
+ );
987
+
988
+ setImportPreview(response.data);
989
+ setConflictActions(initialConflictActions);
990
+ setIsImportDialogOpen(true);
991
+ } catch (error: any) {
992
+ const message =
993
+ error?.response?.data?.message || t('importValidateError');
994
+ toast.error(String(message));
995
+ }
996
+ };
997
+
998
+ const handleConfirmImport = async () => {
999
+ if (!importPreview) return;
1000
+
1001
+ if (importPreview.conflict_slugs.length > 0) {
1002
+ const allResolved = importPreview.conflict_slugs.every(
1003
+ (slug) => !!conflictActions[slug]
1004
+ );
1005
+
1006
+ if (!allResolved) {
1007
+ toast.error(t('importResolveAllConflicts'));
1008
+ return;
1009
+ }
1010
+ }
1011
+
1012
+ setIsImportSubmitting(true);
1013
+ try {
1014
+ const response = await request<{
1015
+ imported: number;
1016
+ }>({
1017
+ url: '/integration-profile/import/confirm',
1018
+ method: 'POST',
1019
+ data: {
1020
+ profiles: importPreview.profiles.map((profile) => ({
1021
+ slug: profile.slug,
1022
+ name: profile.name,
1023
+ type_slug: profile.type_slug,
1024
+ provider_slug: profile.provider_slug,
1025
+ config: profile.config,
1026
+ is_active: profile.is_active,
1027
+ })),
1028
+ conflict_resolutions: Object.entries(conflictActions).map(
1029
+ ([slug, action]) => ({ slug, action })
1030
+ ),
1031
+ },
1032
+ });
1033
+
1034
+ setIsImportDialogOpen(false);
1035
+ setImportPreview(null);
1036
+ setConflictActions({});
1037
+ setSelectedProfileIds(new Set());
1038
+ setRefreshKey((k) => k + 1);
1039
+ toast.success(
1040
+ t('importSuccessCount', { count: Number(response.data.imported ?? 0) })
1041
+ );
1042
+ } catch (error: any) {
1043
+ const message = error?.response?.data?.message || t('importError');
1044
+ toast.error(String(message));
1045
+ } finally {
1046
+ setIsImportSubmitting(false);
1047
+ }
1048
+ };
1049
+
1050
+ const confirmDelete = async () => {
1051
+ if (profileToDelete === null) return;
1052
+ try {
1053
+ await request({
1054
+ url: '/integration-profile',
1055
+ method: 'DELETE',
1056
+ data: { ids: [profileToDelete] },
1057
+ });
1058
+ toast.success(t('deleteSuccess'));
1059
+ setIsDeleteDialogOpen(false);
1060
+ setProfileToDelete(null);
1061
+ setRefreshKey((k) => k + 1);
1062
+ } catch {
1063
+ toast.error(t('deleteError'));
1064
+ }
1065
+ };
1066
+
1067
+ const handleNameChange = (name: string) => {
1068
+ setFormData((p) => ({
1069
+ ...p,
1070
+ name,
1071
+ slug: slugAutoRef.current ? toSlug(name) : p.slug,
1072
+ }));
1073
+ };
1074
+
1075
+ const handleSlugChange = (slug: string) => {
1076
+ slugAutoRef.current = false;
1077
+ setFormData((p) => ({ ...p, slug }));
1078
+ };
1079
+
1080
+ const handleTypeChange = (type_id: string) => {
1081
+ setFormData((p) => ({ ...p, type_id, provider_id: '', config: {} }));
1082
+ };
1083
+
1084
+ const handleProviderChange = (provider_id: string) => {
1085
+ setFormData((p) => ({ ...p, provider_id, config: {} }));
1086
+ setVisibleFields({});
1087
+ };
1088
+
1089
+ const updateConfig = (key: string, value: string | boolean | number) => {
1090
+ setFormData((p) => ({ ...p, config: { ...p.config, [key]: value } }));
1091
+ };
1092
+
1093
+ const handleSubmit = async (event: React.FormEvent) => {
1094
+ event.preventDefault();
1095
+ if (!formData.type_id || !formData.provider_id) return;
1096
+
1097
+ setIsSubmitting(true);
1098
+ const payload = {
1099
+ slug: formData.slug,
1100
+ name: formData.name,
1101
+ type_id: Number(formData.type_id),
1102
+ provider_id: Number(formData.provider_id),
1103
+ config: formData.config,
1104
+ is_active: formData.is_active,
1105
+ };
1106
+
1107
+ try {
1108
+ if (editingProfile) {
1109
+ await request({
1110
+ url: `/integration-profile/${editingProfile.id}`,
1111
+ method: 'PATCH',
1112
+ data: payload,
1113
+ });
1114
+ toast.success(t('updateSuccess'));
1115
+ } else {
1116
+ await request({
1117
+ url: '/integration-profile',
1118
+ method: 'POST',
1119
+ data: payload,
1120
+ });
1121
+ toast.success(t('createSuccess'));
1122
+ }
1123
+ setIsSheetOpen(false);
1124
+ setRefreshKey((k) => k + 1);
1125
+ } catch {
1126
+ toast.error(editingProfile ? t('updateError') : t('createError'));
1127
+ } finally {
1128
+ setIsSubmitting(false);
1129
+ }
1130
+ };
1131
+
1132
+ return (
1133
+ <Page>
1134
+ <PageHeader
1135
+ breadcrumbs={[
1136
+ { label: t('breadcrumbHome'), href: '/' },
1137
+ { label: t('breadcrumbTitle') },
1138
+ ]}
1139
+ actions={[
1140
+ ...(selectedProfileIds.size > 0
1141
+ ? [
1142
+ {
1143
+ label: t('exportSelected'),
1144
+ onClick: () => openExportDialog('selected'),
1145
+ variant: 'outline' as const,
1146
+ },
1147
+ ]
1148
+ : []),
1149
+ {
1150
+ label: t('exportAll'),
1151
+ onClick: () => openExportDialog('all'),
1152
+ variant: 'outline' as const,
1153
+ },
1154
+ {
1155
+ label: t('import'),
1156
+ onClick: handleImportClick,
1157
+ variant: 'outline' as const,
1158
+ },
1159
+ {
1160
+ label: t('newProfile'),
1161
+ onClick: handleCreate,
1162
+ variant: 'default' as const,
1163
+ },
1164
+ ]}
1165
+ title={t('title')}
1166
+ description={t('description')}
1167
+ />
1168
+
1169
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
1170
+ <TabsList>
1171
+ {types.map((ty) => (
1172
+ <TabsTrigger key={ty.slug} value={ty.slug}>
1173
+ {getLocaleName(ty.integration_type_locale, currentLocaleCode)}
1174
+ </TabsTrigger>
1175
+ ))}
1176
+ </TabsList>
1177
+
1178
+ {types.map((ty) => (
1179
+ <TabsContent key={ty.slug} value={ty.slug} className="mt-4">
1180
+ <TypeTab
1181
+ typeId={ty.id}
1182
+ localeCode={currentLocaleCode}
1183
+ types={types}
1184
+ providers={providers}
1185
+ onEdit={handleEdit}
1186
+ onDelete={handleDelete}
1187
+ refreshKey={refreshKey}
1188
+ t={t}
1189
+ getSettingValue={getSettingValue}
1190
+ currentLocaleCode={currentLocaleCode}
1191
+ selectedProfileIds={selectedProfileIds}
1192
+ onToggleProfileSelection={handleToggleProfileSelection}
1193
+ onToggleAllVisible={handleToggleAllVisible}
1194
+ />
1195
+ </TabsContent>
1196
+ ))}
1197
+ </Tabs>
1198
+
1199
+ <input
1200
+ ref={fileInputRef}
1201
+ type="file"
1202
+ accept=".hedhog,application/octet-stream"
1203
+ className="hidden"
1204
+ onChange={handleImportFileChange}
1205
+ />
1206
+
1207
+ <Dialog open={isExportDialogOpen} onOpenChange={setIsExportDialogOpen}>
1208
+ <DialogContent className="max-w-md">
1209
+ <DialogHeader>
1210
+ <DialogTitle>{t('exportDialogTitle')}</DialogTitle>
1211
+ <DialogDescription>
1212
+ {pendingExportScope === 'selected'
1213
+ ? t('exportDialogDescriptionSelected')
1214
+ : t('exportDialogDescriptionAll')}
1215
+ </DialogDescription>
1216
+ </DialogHeader>
1217
+ <label className="flex items-center gap-3 rounded-md border px-3 py-2">
1218
+ <Checkbox
1219
+ checked={exportIncludeSecrets}
1220
+ onCheckedChange={(checked) =>
1221
+ setExportIncludeSecrets(checked === true)
1222
+ }
1223
+ />
1224
+ <span className="text-sm">{t('exportIncludeSecrets')}</span>
1225
+ </label>
1226
+ <DialogFooter>
1227
+ <Button
1228
+ variant="outline"
1229
+ onClick={() => setIsExportDialogOpen(false)}
1230
+ >
1231
+ {t('cancel')}
1232
+ </Button>
1233
+ <Button onClick={handleExport}>{t('export')}</Button>
1234
+ </DialogFooter>
1235
+ </DialogContent>
1236
+ </Dialog>
1237
+
1238
+ <Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
1239
+ <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
1240
+ <DialogHeader>
1241
+ <DialogTitle>{t('importDialogTitle')}</DialogTitle>
1242
+ <DialogDescription>
1243
+ {t('importDialogDescription')}
1244
+ </DialogDescription>
1245
+ </DialogHeader>
1246
+
1247
+ {importPreview && (
1248
+ <div className="space-y-4">
1249
+ <div className="grid gap-2 sm:grid-cols-2">
1250
+ <div className="rounded-md border p-3 text-sm">
1251
+ <p className="text-muted-foreground">
1252
+ {t('importTotalProfiles')}
1253
+ </p>
1254
+ <p className="text-lg font-semibold">
1255
+ {importPreview.total_profiles}
1256
+ </p>
1257
+ </div>
1258
+ <div className="rounded-md border p-3 text-sm">
1259
+ <p className="text-muted-foreground">
1260
+ {t('importValidProfiles')}
1261
+ </p>
1262
+ <p className="text-lg font-semibold">
1263
+ {importPreview.valid_profiles}
1264
+ </p>
1265
+ </div>
1266
+ <div className="rounded-md border p-3 text-sm">
1267
+ <p className="text-muted-foreground">
1268
+ {t('importInvalidProfiles')}
1269
+ </p>
1270
+ <p className="text-lg font-semibold">
1271
+ {importPreview.invalid_profiles}
1272
+ </p>
1273
+ </div>
1274
+ <div className="rounded-md border p-3 text-sm">
1275
+ <p className="text-muted-foreground">
1276
+ {t('importConflictProfiles')}
1277
+ </p>
1278
+ <p className="text-lg font-semibold">
1279
+ {importPreview.conflict_profiles}
1280
+ </p>
1281
+ </div>
1282
+ </div>
1283
+
1284
+ {importPreview.invalid_items.length > 0 && (
1285
+ <div className="space-y-2 rounded-md border p-3">
1286
+ <p className="text-sm font-semibold">
1287
+ {t('importInvalidItemsTitle')}
1288
+ </p>
1289
+ <div className="space-y-2">
1290
+ {importPreview.invalid_items.map((item) => (
1291
+ <div
1292
+ key={item.index}
1293
+ className="rounded border p-2 text-xs"
1294
+ >
1295
+ <p className="font-semibold">
1296
+ {item.slug
1297
+ ? t('importInvalidItemWithSlug', {
1298
+ slug: item.slug,
1299
+ })
1300
+ : t('importInvalidItemWithoutSlug', {
1301
+ index: item.index,
1302
+ })}
1303
+ </p>
1304
+ {item.errors.map((error, errorIndex) => (
1305
+ <p key={errorIndex} className="text-muted-foreground">
1306
+ {error}
1307
+ </p>
1308
+ ))}
1309
+ </div>
1310
+ ))}
1311
+ </div>
1312
+ </div>
1313
+ )}
1314
+
1315
+ {importPreview.conflict_slugs.length > 0 && (
1316
+ <div className="space-y-3 rounded-md border p-3">
1317
+ <p className="text-sm font-semibold">
1318
+ {t('importConflictTitle')}
1319
+ </p>
1320
+ <p className="text-xs text-muted-foreground">
1321
+ {t('importConflictDescription')}
1322
+ </p>
1323
+
1324
+ {importPreview.conflict_slugs.map((slug) => (
1325
+ <div
1326
+ key={slug}
1327
+ className="grid gap-2 rounded border p-2 sm:grid-cols-[1fr_180px] sm:items-center"
1328
+ >
1329
+ <Badge variant="destructive" className="w-fit font-mono">
1330
+ {slug}
1331
+ </Badge>
1332
+ <Select
1333
+ value={conflictActions[slug] ?? 'rename_auto'}
1334
+ onValueChange={(value) =>
1335
+ setConflictActions((prev) => ({
1336
+ ...prev,
1337
+ [slug]: value as
1338
+ | 'ignore'
1339
+ | 'replace'
1340
+ | 'rename_auto',
1341
+ }))
1342
+ }
1343
+ >
1344
+ <SelectTrigger>
1345
+ <SelectValue />
1346
+ </SelectTrigger>
1347
+ <SelectContent>
1348
+ <SelectItem value="ignore">
1349
+ {t('conflictActionIgnore')}
1350
+ </SelectItem>
1351
+ <SelectItem value="replace">
1352
+ {t('conflictActionReplace')}
1353
+ </SelectItem>
1354
+ <SelectItem value="rename_auto">
1355
+ {t('conflictActionRenameAuto')}
1356
+ </SelectItem>
1357
+ </SelectContent>
1358
+ </Select>
1359
+ </div>
1360
+ ))}
1361
+ </div>
1362
+ )}
1363
+ </div>
1364
+ )}
1365
+
1366
+ <DialogFooter>
1367
+ <Button
1368
+ variant="outline"
1369
+ onClick={() => {
1370
+ setIsImportDialogOpen(false);
1371
+ setImportPreview(null);
1372
+ setConflictActions({});
1373
+ }}
1374
+ disabled={isImportSubmitting}
1375
+ >
1376
+ {t('cancel')}
1377
+ </Button>
1378
+ <Button onClick={handleConfirmImport} disabled={isImportSubmitting}>
1379
+ {isImportSubmitting ? t('importing') : t('confirmImport')}
1380
+ </Button>
1381
+ </DialogFooter>
1382
+ </DialogContent>
1383
+ </Dialog>
1384
+
1385
+ {/* ── Create / Edit Sheet ── */}
1386
+ <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
1387
+ <ResizableSheetContent
1388
+ sheetId="integration-profile-form"
1389
+ defaultWidth={680}
1390
+ >
1391
+ <SheetHeader>
1392
+ <SheetTitle>
1393
+ {editingProfile ? t('editProfile') : t('newProfileTitle')}
1394
+ </SheetTitle>
1395
+ <SheetDescription>
1396
+ {editingProfile
1397
+ ? t('editProfileDescription')
1398
+ : t('newProfileDescription')}
1399
+ </SheetDescription>
1400
+ </SheetHeader>
1401
+
1402
+ <form
1403
+ onSubmit={handleSubmit}
1404
+ className="flex flex-col gap-6 px-3 pb-8 sm:px-4"
1405
+ >
1406
+ {/* ── Identity ── */}
1407
+ <div className="grid gap-4 sm:grid-cols-2">
1408
+ <div className="space-y-2">
1409
+ <Label htmlFor="ip-name">
1410
+ {t('nameLabel')} <span className="text-destructive">*</span>
1411
+ </Label>
1412
+ <Input
1413
+ id="ip-name"
1414
+ value={formData.name}
1415
+ onChange={(e) => handleNameChange(e.target.value)}
1416
+ placeholder={t('namePlaceholder')}
1417
+ required
1418
+ autoFocus
1419
+ />
1420
+ </div>
1421
+
1422
+ <div className="space-y-2">
1423
+ <Label htmlFor="ip-slug">
1424
+ {t('slugLabel')} <span className="text-destructive">*</span>
1425
+ </Label>
1426
+ <Input
1427
+ id="ip-slug"
1428
+ value={formData.slug}
1429
+ onChange={(e) => handleSlugChange(e.target.value)}
1430
+ placeholder={t('slugPlaceholder')}
1431
+ required
1432
+ className="font-mono text-sm"
1433
+ />
1434
+ </div>
1435
+ </div>
1436
+
1437
+ {/* ── Type + Provider ── */}
1438
+ <div className="grid gap-4 sm:grid-cols-2">
1439
+ <div className="space-y-2">
1440
+ <Label htmlFor="ip-type">
1441
+ {t('typeLabel')} <span className="text-destructive">*</span>
1442
+ </Label>
1443
+ <Select
1444
+ value={formData.type_id}
1445
+ onValueChange={handleTypeChange}
1446
+ disabled={!!editingProfile}
1447
+ >
1448
+ <SelectTrigger className="w-full" id="ip-type">
1449
+ <SelectValue placeholder={t('typePlaceholder')} />
1450
+ </SelectTrigger>
1451
+ <SelectContent>
1452
+ {types.map((ty) => (
1453
+ <SelectItem key={ty.id} value={String(ty.id)}>
1454
+ {getLocaleName(
1455
+ ty.integration_type_locale,
1456
+ currentLocaleCode
1457
+ )}
1458
+ </SelectItem>
1459
+ ))}
1460
+ </SelectContent>
1461
+ </Select>
1462
+ </div>
1463
+
1464
+ <div className="space-y-2">
1465
+ <Label htmlFor="ip-provider">
1466
+ {t('providerLabel')}{' '}
1467
+ <span className="text-destructive">*</span>
1468
+ </Label>
1469
+ <Select
1470
+ value={formData.provider_id}
1471
+ onValueChange={handleProviderChange}
1472
+ disabled={!formData.type_id || !!editingProfile}
1473
+ >
1474
+ <SelectTrigger className="w-full" id="ip-provider">
1475
+ <SelectValue
1476
+ placeholder={
1477
+ !formData.type_id
1478
+ ? t('providerSelectTypFirst')
1479
+ : t('providerPlaceholder')
1480
+ }
1481
+ />
1482
+ </SelectTrigger>
1483
+ <SelectContent>
1484
+ {formTypeProviders.map((prov) => (
1485
+ <SelectItem key={prov.id} value={String(prov.id)}>
1486
+ <span className="flex items-center gap-2">
1487
+ <Avatar className="size-6 border bg-muted/40">
1488
+ <AvatarFallback className="text-[10px] font-semibold">
1489
+ <IntegrationLogo
1490
+ provider={prov.slug}
1491
+ size={14}
1492
+ decorative
1493
+ />
1494
+ </AvatarFallback>
1495
+ </Avatar>
1496
+ {getLocaleName(
1497
+ prov.integration_provider_locale,
1498
+ currentLocaleCode
1499
+ )}
1500
+ </span>
1501
+ </SelectItem>
1502
+ ))}
1503
+ </SelectContent>
1504
+ </Select>
1505
+ </div>
1506
+ </div>
1507
+
1508
+ {/* ── Dynamic credential fields ── */}
1509
+ {providerFields.length > 0 && (
1510
+ <div className="rounded-lg border">
1511
+ <div className="border-b px-4 py-3">
1512
+ <p className="text-sm font-semibold">
1513
+ {t('configSectionTitle')}
1514
+ </p>
1515
+ </div>
1516
+ <div className="grid gap-4 p-4 sm:grid-cols-2">
1517
+ {providerFields.map((field) => {
1518
+ if (field.type === 'boolean') {
1519
+ return (
1520
+ <div
1521
+ key={field.key}
1522
+ className="col-span-full flex items-center justify-between rounded-md border px-4 py-3"
1523
+ >
1524
+ <p className="text-sm font-medium">
1525
+ {t(field.labelKey as any)}
1526
+ </p>
1527
+ <Switch
1528
+ checked={Boolean(formData.config[field.key])}
1529
+ onCheckedChange={(checked) =>
1530
+ updateConfig(field.key, checked)
1531
+ }
1532
+ />
1533
+ </div>
1534
+ );
1535
+ }
1536
+
1537
+ const isPassword = field.type === 'password';
1538
+ const isVisible = visibleFields[field.key] ?? false;
1539
+
1540
+ return (
1541
+ <div
1542
+ key={field.key}
1543
+ className={
1544
+ field.colSpan
1545
+ ? 'col-span-full space-y-2'
1546
+ : 'space-y-2'
1547
+ }
1548
+ >
1549
+ <Label htmlFor={`ip-cfg-${field.key}`}>
1550
+ {t(field.labelKey as any)}
1551
+ {field.required && (
1552
+ <span className="text-destructive"> *</span>
1553
+ )}
1554
+ </Label>
1555
+ <div className={isPassword ? 'relative' : undefined}>
1556
+ <Input
1557
+ id={`ip-cfg-${field.key}`}
1558
+ type={
1559
+ isPassword
1560
+ ? isVisible
1561
+ ? 'text'
1562
+ : 'password'
1563
+ : field.type === 'number'
1564
+ ? 'number'
1565
+ : 'text'
1566
+ }
1567
+ value={String(formData.config[field.key] ?? '')}
1568
+ onChange={(e) =>
1569
+ updateConfig(field.key, e.target.value)
1570
+ }
1571
+ placeholder={field.placeholder}
1572
+ required={field.required}
1573
+ autoComplete={
1574
+ isPassword ? 'new-password' : undefined
1575
+ }
1576
+ className={isPassword ? 'pr-10' : undefined}
1577
+ />
1578
+ {isPassword && (
1579
+ <Button
1580
+ type="button"
1581
+ variant="ghost"
1582
+ size="icon"
1583
+ className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
1584
+ onClick={() =>
1585
+ setVisibleFields((p) => ({
1586
+ ...p,
1587
+ [field.key]: !p[field.key],
1588
+ }))
1589
+ }
1590
+ aria-label={
1591
+ isVisible
1592
+ ? t('hidePassword')
1593
+ : t('showPassword')
1594
+ }
1595
+ >
1596
+ {isVisible ? (
1597
+ <EyeOff className="h-4 w-4" />
1598
+ ) : (
1599
+ <Eye className="h-4 w-4" />
1600
+ )}
1601
+ </Button>
1602
+ )}
1603
+ </div>
1604
+ </div>
1605
+ );
1606
+ })}
1607
+ </div>
1608
+ </div>
1609
+ )}
1610
+
1611
+ {/* ── Active ── */}
1612
+ <div className="flex items-center justify-between rounded-md border px-4 py-3">
1613
+ <p className="text-sm font-medium">{t('isActiveLabel')}</p>
1614
+ <Switch
1615
+ checked={formData.is_active}
1616
+ onCheckedChange={(checked) =>
1617
+ setFormData((p) => ({ ...p, is_active: checked }))
1618
+ }
1619
+ />
1620
+ </div>
1621
+
1622
+ <div className="flex justify-end border-t pt-4">
1623
+ <Button
1624
+ type="submit"
1625
+ className="min-w-40"
1626
+ disabled={
1627
+ isSubmitting || !formData.type_id || !formData.provider_id
1628
+ }
1629
+ >
1630
+ {editingProfile ? t('saveChanges') : t('createProfile')}
1631
+ </Button>
1632
+ </div>
1633
+ </form>
1634
+ </ResizableSheetContent>
1635
+ </Sheet>
1636
+
1637
+ {/* ── Delete confirmation ── */}
1638
+ <AlertDialog
1639
+ open={isDeleteDialogOpen}
1640
+ onOpenChange={setIsDeleteDialogOpen}
1641
+ >
1642
+ <AlertDialogContent>
1643
+ <AlertDialogHeader>
1644
+ <AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
1645
+ <AlertDialogDescription>
1646
+ {t('deleteConfirmDescription')}
1647
+ </AlertDialogDescription>
1648
+ </AlertDialogHeader>
1649
+ <AlertDialogFooter>
1650
+ <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
1651
+ <AlertDialogAction
1652
+ onClick={confirmDelete}
1653
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
1654
+ >
1655
+ {t('delete')}
1656
+ </AlertDialogAction>
1657
+ </AlertDialogFooter>
1658
+ </AlertDialogContent>
1659
+ </AlertDialog>
1660
+ </Page>
1661
+ );
1662
+ }