@hed-hog/core 0.0.355 → 0.0.358

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