@open-mercato/core 0.4.6-develop-6d72ec5960 → 0.4.6-develop-cd1e2a9a0e

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 (226) hide show
  1. package/AGENTS.md +10 -0
  2. package/dist/generated/entities/integration_credentials/index.js +19 -0
  3. package/dist/generated/entities/integration_credentials/index.js.map +7 -0
  4. package/dist/generated/entities/integration_log/index.js +27 -0
  5. package/dist/generated/entities/integration_log/index.js.map +7 -0
  6. package/dist/generated/entities/integration_state/index.js +27 -0
  7. package/dist/generated/entities/integration_state/index.js.map +7 -0
  8. package/dist/generated/entities/sync_cursor/index.js +19 -0
  9. package/dist/generated/entities/sync_cursor/index.js.map +7 -0
  10. package/dist/generated/entities/sync_external_id_mapping/index.js +27 -0
  11. package/dist/generated/entities/sync_external_id_mapping/index.js.map +7 -0
  12. package/dist/generated/entities/sync_mapping/index.js +19 -0
  13. package/dist/generated/entities/sync_mapping/index.js.map +7 -0
  14. package/dist/generated/entities/sync_run/index.js +45 -0
  15. package/dist/generated/entities/sync_run/index.js.map +7 -0
  16. package/dist/generated/entities/sync_schedule/index.js +35 -0
  17. package/dist/generated/entities/sync_schedule/index.js.map +7 -0
  18. package/dist/generated/entities.ids.generated.js +14 -0
  19. package/dist/generated/entities.ids.generated.js.map +2 -2
  20. package/dist/generated/entity-fields-registry.js +16 -0
  21. package/dist/generated/entity-fields-registry.js.map +2 -2
  22. package/dist/modules/data_sync/acl.js +11 -0
  23. package/dist/modules/data_sync/acl.js.map +7 -0
  24. package/dist/modules/data_sync/api/mappings/[id]/route.js +137 -0
  25. package/dist/modules/data_sync/api/mappings/[id]/route.js.map +7 -0
  26. package/dist/modules/data_sync/api/mappings/route.js +132 -0
  27. package/dist/modules/data_sync/api/mappings/route.js.map +7 -0
  28. package/dist/modules/data_sync/api/run.js +87 -0
  29. package/dist/modules/data_sync/api/run.js.map +7 -0
  30. package/dist/modules/data_sync/api/runs/[id]/cancel.js +49 -0
  31. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +7 -0
  32. package/dist/modules/data_sync/api/runs/[id]/retry.js +93 -0
  33. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +7 -0
  34. package/dist/modules/data_sync/api/runs/[id]/route.js +69 -0
  35. package/dist/modules/data_sync/api/runs/[id]/route.js.map +7 -0
  36. package/dist/modules/data_sync/api/runs.js +66 -0
  37. package/dist/modules/data_sync/api/runs.js.map +7 -0
  38. package/dist/modules/data_sync/api/validate.js +66 -0
  39. package/dist/modules/data_sync/api/validate.js.map +7 -0
  40. package/dist/modules/data_sync/backend/data-sync/page.js +216 -0
  41. package/dist/modules/data_sync/backend/data-sync/page.js.map +7 -0
  42. package/dist/modules/data_sync/backend/data-sync/page.meta.js +25 -0
  43. package/dist/modules/data_sync/backend/data-sync/page.meta.js.map +7 -0
  44. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +178 -0
  45. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +7 -0
  46. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.meta.js +14 -0
  47. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.meta.js.map +7 -0
  48. package/dist/modules/data_sync/data/entities.js +228 -0
  49. package/dist/modules/data_sync/data/entities.js.map +7 -0
  50. package/dist/modules/data_sync/data/validators.js +32 -0
  51. package/dist/modules/data_sync/data/validators.js.map +7 -0
  52. package/dist/modules/data_sync/di.js +26 -0
  53. package/dist/modules/data_sync/di.js.map +7 -0
  54. package/dist/modules/data_sync/events.js +16 -0
  55. package/dist/modules/data_sync/events.js.map +7 -0
  56. package/dist/modules/data_sync/index.js +9 -0
  57. package/dist/modules/data_sync/index.js.map +7 -0
  58. package/dist/modules/data_sync/lib/adapter-registry.js +16 -0
  59. package/dist/modules/data_sync/lib/adapter-registry.js.map +7 -0
  60. package/dist/modules/data_sync/lib/adapter.js +1 -0
  61. package/dist/modules/data_sync/lib/adapter.js.map +7 -0
  62. package/dist/modules/data_sync/lib/id-mapping.js +79 -0
  63. package/dist/modules/data_sync/lib/id-mapping.js.map +7 -0
  64. package/dist/modules/data_sync/lib/queue.js +17 -0
  65. package/dist/modules/data_sync/lib/queue.js.map +7 -0
  66. package/dist/modules/data_sync/lib/sync-engine.js +309 -0
  67. package/dist/modules/data_sync/lib/sync-engine.js.map +7 -0
  68. package/dist/modules/data_sync/lib/sync-run-service.js +148 -0
  69. package/dist/modules/data_sync/lib/sync-run-service.js.map +7 -0
  70. package/dist/modules/data_sync/migrations/Migration20260304113737.js +17 -0
  71. package/dist/modules/data_sync/migrations/Migration20260304113737.js.map +7 -0
  72. package/dist/modules/data_sync/setup.js +13 -0
  73. package/dist/modules/data_sync/setup.js.map +7 -0
  74. package/dist/modules/data_sync/workers/sync-export.js +14 -0
  75. package/dist/modules/data_sync/workers/sync-export.js.map +7 -0
  76. package/dist/modules/data_sync/workers/sync-import.js +14 -0
  77. package/dist/modules/data_sync/workers/sync-import.js.map +7 -0
  78. package/dist/modules/data_sync/workers/sync-scheduled.js +63 -0
  79. package/dist/modules/data_sync/workers/sync-scheduled.js.map +7 -0
  80. package/dist/modules/entities/lib/encryptionDefaults.js +4 -0
  81. package/dist/modules/entities/lib/encryptionDefaults.js.map +2 -2
  82. package/dist/modules/integrations/acl.js +4 -1
  83. package/dist/modules/integrations/acl.js.map +2 -2
  84. package/dist/modules/integrations/api/[id]/credentials/route.js +127 -0
  85. package/dist/modules/integrations/api/[id]/credentials/route.js.map +7 -0
  86. package/dist/modules/integrations/api/[id]/health/route.js +46 -0
  87. package/dist/modules/integrations/api/[id]/health/route.js.map +7 -0
  88. package/dist/modules/integrations/api/[id]/route.js +65 -0
  89. package/dist/modules/integrations/api/[id]/route.js.map +7 -0
  90. package/dist/modules/integrations/api/[id]/state/route.js +109 -0
  91. package/dist/modules/integrations/api/[id]/state/route.js.map +7 -0
  92. package/dist/modules/integrations/api/[id]/version/route.js +117 -0
  93. package/dist/modules/integrations/api/[id]/version/route.js.map +7 -0
  94. package/dist/modules/integrations/api/guards.js +31 -0
  95. package/dist/modules/integrations/api/guards.js.map +7 -0
  96. package/dist/modules/integrations/api/logs/route.js +60 -0
  97. package/dist/modules/integrations/api/logs/route.js.map +7 -0
  98. package/dist/modules/integrations/api/openapi.js +25 -0
  99. package/dist/modules/integrations/api/openapi.js.map +7 -0
  100. package/dist/modules/integrations/api/route.js +68 -0
  101. package/dist/modules/integrations/api/route.js.map +7 -0
  102. package/dist/modules/integrations/backend/integrations/[id]/page.js +313 -0
  103. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +7 -0
  104. package/dist/modules/integrations/backend/integrations/[id]/page.meta.js +15 -0
  105. package/dist/modules/integrations/backend/integrations/[id]/page.meta.js.map +7 -0
  106. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +189 -0
  107. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +7 -0
  108. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.meta.js +15 -0
  109. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.meta.js.map +7 -0
  110. package/dist/modules/integrations/backend/integrations/page.js +212 -0
  111. package/dist/modules/integrations/backend/integrations/page.js.map +7 -0
  112. package/dist/modules/integrations/backend/integrations/page.meta.js +22 -0
  113. package/dist/modules/integrations/backend/integrations/page.meta.js.map +7 -0
  114. package/dist/modules/integrations/data/enrichers.js +27 -12
  115. package/dist/modules/integrations/data/enrichers.js.map +2 -2
  116. package/dist/modules/integrations/data/entities.js +136 -1
  117. package/dist/modules/integrations/data/entities.js.map +2 -2
  118. package/dist/modules/integrations/data/validators.js +36 -0
  119. package/dist/modules/integrations/data/validators.js.map +7 -0
  120. package/dist/modules/integrations/di.js +24 -0
  121. package/dist/modules/integrations/di.js.map +7 -0
  122. package/dist/modules/integrations/events.js +19 -0
  123. package/dist/modules/integrations/events.js.map +7 -0
  124. package/dist/modules/integrations/lib/credentials-service.js +159 -0
  125. package/dist/modules/integrations/lib/credentials-service.js.map +7 -0
  126. package/dist/modules/integrations/lib/health-service.js +37 -0
  127. package/dist/modules/integrations/lib/health-service.js.map +7 -0
  128. package/dist/modules/integrations/lib/log-service.js +66 -0
  129. package/dist/modules/integrations/lib/log-service.js.map +7 -0
  130. package/dist/modules/integrations/lib/registry-service.js +33 -0
  131. package/dist/modules/integrations/lib/registry-service.js.map +7 -0
  132. package/dist/modules/integrations/lib/state-service.js +55 -0
  133. package/dist/modules/integrations/lib/state-service.js.map +7 -0
  134. package/dist/modules/integrations/lib/types.js +1 -0
  135. package/dist/modules/integrations/lib/types.js.map +7 -0
  136. package/dist/modules/integrations/migrations/Migration20260304113737.js +19 -0
  137. package/dist/modules/integrations/migrations/Migration20260304113737.js.map +7 -0
  138. package/dist/modules/integrations/setup.js +2 -2
  139. package/dist/modules/integrations/setup.js.map +2 -2
  140. package/dist/modules/integrations/widgets/injection-table.js.map +1 -1
  141. package/dist/modules/integrations/workers/log-pruner.js +18 -0
  142. package/dist/modules/integrations/workers/log-pruner.js.map +7 -0
  143. package/generated/entities/integration_credentials/index.ts +8 -0
  144. package/generated/entities/integration_log/index.ts +12 -0
  145. package/generated/entities/integration_state/index.ts +12 -0
  146. package/generated/entities/sync_cursor/index.ts +8 -0
  147. package/generated/entities/sync_external_id_mapping/index.ts +12 -0
  148. package/generated/entities/sync_mapping/index.ts +8 -0
  149. package/generated/entities/sync_run/index.ts +21 -0
  150. package/generated/entities/sync_schedule/index.ts +16 -0
  151. package/generated/entities.ids.generated.ts +14 -0
  152. package/generated/entity-fields-registry.ts +16 -0
  153. package/package.json +2 -2
  154. package/src/modules/data_sync/AGENTS.md +157 -0
  155. package/src/modules/data_sync/acl.ts +7 -0
  156. package/src/modules/data_sync/api/mappings/[id]/route.ts +158 -0
  157. package/src/modules/data_sync/api/mappings/route.ts +144 -0
  158. package/src/modules/data_sync/api/run.ts +97 -0
  159. package/src/modules/data_sync/api/runs/[id]/cancel.ts +57 -0
  160. package/src/modules/data_sync/api/runs/[id]/retry.ts +108 -0
  161. package/src/modules/data_sync/api/runs/[id]/route.ts +81 -0
  162. package/src/modules/data_sync/api/runs.ts +69 -0
  163. package/src/modules/data_sync/api/validate.ts +73 -0
  164. package/src/modules/data_sync/backend/data-sync/page.meta.ts +21 -0
  165. package/src/modules/data_sync/backend/data-sync/page.tsx +244 -0
  166. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.meta.ts +10 -0
  167. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +278 -0
  168. package/src/modules/data_sync/data/entities.ts +180 -0
  169. package/src/modules/data_sync/data/validators.ts +35 -0
  170. package/src/modules/data_sync/di.ts +38 -0
  171. package/src/modules/data_sync/events.ts +12 -0
  172. package/src/modules/data_sync/i18n/de.json +48 -0
  173. package/src/modules/data_sync/i18n/en.json +48 -0
  174. package/src/modules/data_sync/i18n/es.json +48 -0
  175. package/src/modules/data_sync/i18n/pl.json +48 -0
  176. package/src/modules/data_sync/index.ts +5 -0
  177. package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
  178. package/src/modules/data_sync/lib/adapter.ts +90 -0
  179. package/src/modules/data_sync/lib/id-mapping.ts +95 -0
  180. package/src/modules/data_sync/lib/queue.ts +19 -0
  181. package/src/modules/data_sync/lib/sync-engine.ts +375 -0
  182. package/src/modules/data_sync/lib/sync-run-service.ts +187 -0
  183. package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +653 -0
  184. package/src/modules/data_sync/migrations/Migration20260304113737.ts +19 -0
  185. package/src/modules/data_sync/setup.ts +11 -0
  186. package/src/modules/data_sync/workers/sync-export.ts +27 -0
  187. package/src/modules/data_sync/workers/sync-import.ts +27 -0
  188. package/src/modules/data_sync/workers/sync-scheduled.ts +84 -0
  189. package/src/modules/entities/lib/encryptionDefaults.ts +4 -0
  190. package/src/modules/integrations/AGENTS.md +160 -0
  191. package/src/modules/integrations/acl.ts +3 -0
  192. package/src/modules/integrations/api/[id]/credentials/route.ts +142 -0
  193. package/src/modules/integrations/api/[id]/health/route.ts +53 -0
  194. package/src/modules/integrations/api/[id]/route.ts +76 -0
  195. package/src/modules/integrations/api/[id]/state/route.ts +121 -0
  196. package/src/modules/integrations/api/[id]/version/route.ts +132 -0
  197. package/src/modules/integrations/api/guards.ts +59 -0
  198. package/src/modules/integrations/api/logs/route.ts +63 -0
  199. package/src/modules/integrations/api/openapi.ts +22 -0
  200. package/src/modules/integrations/api/route.ts +73 -0
  201. package/src/modules/integrations/backend/integrations/[id]/page.meta.ts +11 -0
  202. package/src/modules/integrations/backend/integrations/[id]/page.tsx +424 -0
  203. package/src/modules/integrations/backend/integrations/bundle/[id]/page.meta.ts +11 -0
  204. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +249 -0
  205. package/src/modules/integrations/backend/integrations/page.meta.ts +18 -0
  206. package/src/modules/integrations/backend/integrations/page.tsx +296 -0
  207. package/src/modules/integrations/data/enrichers.ts +35 -18
  208. package/src/modules/integrations/data/entities.ts +114 -5
  209. package/src/modules/integrations/data/validators.ts +41 -0
  210. package/src/modules/integrations/di.ts +31 -0
  211. package/src/modules/integrations/events.ts +17 -0
  212. package/src/modules/integrations/i18n/de.json +70 -0
  213. package/src/modules/integrations/i18n/en.json +70 -0
  214. package/src/modules/integrations/i18n/es.json +70 -0
  215. package/src/modules/integrations/i18n/pl.json +70 -0
  216. package/src/modules/integrations/lib/credentials-service.ts +204 -0
  217. package/src/modules/integrations/lib/health-service.ts +59 -0
  218. package/src/modules/integrations/lib/log-service.ts +84 -0
  219. package/src/modules/integrations/lib/registry-service.ts +42 -0
  220. package/src/modules/integrations/lib/state-service.ts +64 -0
  221. package/src/modules/integrations/lib/types.ts +4 -0
  222. package/src/modules/integrations/migrations/.snapshot-open-mercato.json +582 -0
  223. package/src/modules/integrations/migrations/Migration20260304113737.ts +21 -0
  224. package/src/modules/integrations/setup.ts +2 -2
  225. package/src/modules/integrations/widgets/injection-table.ts +1 -1
  226. package/src/modules/integrations/workers/log-pruner.ts +30 -0
@@ -0,0 +1,424 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { useParams } from 'next/navigation'
5
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import { Card, CardHeader, CardTitle, CardContent } from '@open-mercato/ui/primitives/card'
7
+ import { Badge } from '@open-mercato/ui/primitives/badge'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Switch } from '@open-mercato/ui/primitives/switch'
10
+ import { Input } from '@open-mercato/ui/primitives/input'
11
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
13
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
14
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
15
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
+ import type { IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
17
+ import { LoadingMessage } from '@open-mercato/ui/backend/detail'
18
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
19
+
20
+ type CredentialField = IntegrationCredentialField
21
+
22
+ type ApiVersion = {
23
+ id: string
24
+ label?: string
25
+ status: 'stable' | 'deprecated' | 'experimental'
26
+ sunsetAt?: string
27
+ migrationGuide?: string
28
+ }
29
+
30
+ type IntegrationDetail = {
31
+ integration: {
32
+ id: string
33
+ title: string
34
+ description?: string
35
+ category?: string
36
+ hub?: string
37
+ bundleId?: string
38
+ docsUrl?: string
39
+ apiVersions?: ApiVersion[]
40
+ credentials?: { fields: CredentialField[] }
41
+ }
42
+ bundle?: { id: string; title: string; credentials?: { fields: CredentialField[] } }
43
+ state: {
44
+ isEnabled: boolean
45
+ apiVersion: string | null
46
+ reauthRequired: boolean
47
+ lastHealthStatus: string | null
48
+ lastHealthCheckedAt: string | null
49
+ }
50
+ hasCredentials: boolean
51
+ }
52
+
53
+ type LogEntry = {
54
+ id: string
55
+ level: 'info' | 'warn' | 'error'
56
+ message: string
57
+ createdAt: string
58
+ code?: string
59
+ }
60
+
61
+ const LOG_LEVEL_STYLES: Record<string, string> = {
62
+ info: 'bg-blue-100 text-blue-800',
63
+ warn: 'bg-yellow-100 text-yellow-800',
64
+ error: 'bg-red-100 text-red-800',
65
+ }
66
+
67
+ const HEALTH_STATUS_STYLES: Record<string, string> = {
68
+ healthy: 'bg-green-100 text-green-800',
69
+ degraded: 'bg-yellow-100 text-yellow-800',
70
+ unhealthy: 'bg-red-100 text-red-800',
71
+ }
72
+
73
+ export default function IntegrationDetailPage() {
74
+ const params = useParams<{ id: string }>()
75
+ const integrationId = params.id
76
+ const t = useT()
77
+
78
+ const [detail, setDetail] = React.useState<IntegrationDetail | null>(null)
79
+ const [isLoading, setIsLoading] = React.useState(true)
80
+ const [error, setError] = React.useState<string | null>(null)
81
+
82
+ const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
83
+ const [isSavingCreds, setIsSavingCreds] = React.useState(false)
84
+
85
+ const [logs, setLogs] = React.useState<LogEntry[]>([])
86
+ const [logLevel, setLogLevel] = React.useState<string>('')
87
+ const [isLoadingLogs, setIsLoadingLogs] = React.useState(false)
88
+
89
+ const [isCheckingHealth, setIsCheckingHealth] = React.useState(false)
90
+ const [isTogglingState, setIsTogglingState] = React.useState(false)
91
+
92
+ const loadDetail = React.useCallback(async () => {
93
+ setIsLoading(true)
94
+ setError(null)
95
+ const call = await apiCall<IntegrationDetail>(
96
+ `/api/integrations/${encodeURIComponent(integrationId)}`,
97
+ undefined,
98
+ { fallback: null },
99
+ )
100
+ if (!call.ok || !call.result) {
101
+ setError(t('integrations.detail.loadError'))
102
+ setIsLoading(false)
103
+ return
104
+ }
105
+ setDetail(call.result)
106
+ setIsLoading(false)
107
+ }, [integrationId, t])
108
+
109
+ const loadCredentials = React.useCallback(async () => {
110
+ const call = await apiCall<{ credentials: Record<string, unknown> }>(
111
+ `/api/integrations/${encodeURIComponent(integrationId)}/credentials`,
112
+ undefined,
113
+ { fallback: null },
114
+ )
115
+ if (call.ok && call.result?.credentials) {
116
+ setCredValues(call.result.credentials)
117
+ }
118
+ }, [integrationId])
119
+
120
+ const loadLogs = React.useCallback(async () => {
121
+ setIsLoadingLogs(true)
122
+ const params = new URLSearchParams({ integrationId, pageSize: '50' })
123
+ if (logLevel) params.set('level', logLevel)
124
+ const call = await apiCall<{ items: LogEntry[] }>(
125
+ `/api/integrations/logs?${params.toString()}`,
126
+ undefined,
127
+ { fallback: { items: [] } },
128
+ )
129
+ if (call.ok && call.result) {
130
+ setLogs(call.result.items)
131
+ }
132
+ setIsLoadingLogs(false)
133
+ }, [integrationId, logLevel])
134
+
135
+ React.useEffect(() => { void loadDetail() }, [loadDetail])
136
+ React.useEffect(() => { void loadCredentials() }, [loadCredentials])
137
+ React.useEffect(() => { void loadLogs() }, [loadLogs])
138
+
139
+ const handleToggleState = React.useCallback(async (enabled: boolean) => {
140
+ setIsTogglingState(true)
141
+ const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
142
+ method: 'PUT',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({ isEnabled: enabled }),
145
+ }, { fallback: null })
146
+ if (call.ok) {
147
+ setDetail((prev) => prev ? { ...prev, state: { ...prev.state, isEnabled: enabled } } : prev)
148
+ flash(t('integrations.detail.stateUpdated'), 'success')
149
+ } else {
150
+ flash(t('integrations.detail.stateError'), 'error')
151
+ }
152
+ setIsTogglingState(false)
153
+ }, [integrationId, t])
154
+
155
+ const handleSaveCredentials = React.useCallback(async () => {
156
+ setIsSavingCreds(true)
157
+ const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/credentials`, {
158
+ method: 'PUT',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ credentials: credValues }),
161
+ }, { fallback: null })
162
+ if (call.ok) {
163
+ flash(t('integrations.detail.credentials.saved'), 'success')
164
+ } else {
165
+ flash(t('integrations.detail.credentials.saveError'), 'error')
166
+ }
167
+ setIsSavingCreds(false)
168
+ }, [integrationId, credValues, t])
169
+
170
+ const handleVersionChange = React.useCallback(async (version: string) => {
171
+ const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/version`, {
172
+ method: 'PUT',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ apiVersion: version }),
175
+ }, { fallback: null })
176
+ if (call.ok) {
177
+ setDetail((prev) => prev ? { ...prev, state: { ...prev.state, apiVersion: version } } : prev)
178
+ flash(t('integrations.detail.version.saved'), 'success')
179
+ } else {
180
+ flash(t('integrations.detail.version.saveError'), 'error')
181
+ }
182
+ }, [integrationId, t])
183
+
184
+ const handleHealthCheck = React.useCallback(async () => {
185
+ setIsCheckingHealth(true)
186
+ const call = await apiCall<{ status: string; checkedAt: string }>(
187
+ `/api/integrations/${encodeURIComponent(integrationId)}/health`,
188
+ { method: 'POST' },
189
+ { fallback: null },
190
+ )
191
+ if (call.ok && call.result) {
192
+ setDetail((prev) => prev ? {
193
+ ...prev,
194
+ state: {
195
+ ...prev.state,
196
+ lastHealthStatus: call.result!.status,
197
+ lastHealthCheckedAt: call.result!.checkedAt,
198
+ },
199
+ } : prev)
200
+ } else {
201
+ flash(t('integrations.detail.health.checkError'), 'error')
202
+ }
203
+ setIsCheckingHealth(false)
204
+ }, [integrationId, t])
205
+
206
+ if (isLoading) return <Page><PageBody><LoadingMessage label={t('integrations.detail.title')} /></PageBody></Page>
207
+ if (error || !detail) return <Page><PageBody><ErrorMessage label={error ?? t('integrations.detail.loadError')} /></PageBody></Page>
208
+
209
+ const { integration, state } = detail
210
+ const credFields = integration.credentials?.fields ?? detail.bundle?.credentials?.fields ?? []
211
+ const hasVersions = Boolean(integration.apiVersions?.length)
212
+
213
+ return (
214
+ <Page>
215
+ <PageBody className="space-y-6">
216
+ <div>
217
+ <Link href="/backend/integrations" className="text-sm text-muted-foreground hover:underline">
218
+ {t('integrations.detail.back')}
219
+ </Link>
220
+ </div>
221
+
222
+ <div className="flex items-center justify-between">
223
+ <div>
224
+ <h1 className="text-2xl font-semibold">{integration.title}</h1>
225
+ {integration.description && (
226
+ <p className="text-muted-foreground mt-1">{integration.description}</p>
227
+ )}
228
+ <div className="flex gap-2 mt-2">
229
+ {integration.category && <Badge variant="secondary">{integration.category}</Badge>}
230
+ {integration.hub && <Badge variant="outline">{integration.hub}</Badge>}
231
+ </div>
232
+ </div>
233
+ <div className="flex items-center gap-3">
234
+ <span className="text-sm text-muted-foreground">
235
+ {state.isEnabled ? t('integrations.detail.enable') : t('integrations.detail.disable')}
236
+ </span>
237
+ <Switch
238
+ checked={state.isEnabled}
239
+ disabled={isTogglingState}
240
+ onCheckedChange={(checked) => void handleToggleState(checked)}
241
+ />
242
+ </div>
243
+ </div>
244
+
245
+ <Tabs defaultValue="credentials">
246
+ <TabsList>
247
+ <TabsTrigger value="credentials">{t('integrations.detail.tabs.credentials')}</TabsTrigger>
248
+ {hasVersions && <TabsTrigger value="version">{t('integrations.detail.tabs.version')}</TabsTrigger>}
249
+ <TabsTrigger value="health">{t('integrations.detail.tabs.health')}</TabsTrigger>
250
+ <TabsTrigger value="logs">{t('integrations.detail.tabs.logs')}</TabsTrigger>
251
+ </TabsList>
252
+
253
+ <TabsContent value="credentials" className="space-y-4 mt-4">
254
+ {detail.bundle && (
255
+ <div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
256
+ {t('integrations.detail.credentials.bundleShared', { bundle: detail.bundle.title })}
257
+ </div>
258
+ )}
259
+ {credFields.length === 0 ? (
260
+ <p className="text-muted-foreground text-sm">{t('integrations.detail.credentials.notConfigured')}</p>
261
+ ) : (
262
+ <Card>
263
+ <CardContent className="pt-6 space-y-4">
264
+ {credFields.filter((f) => f.type !== 'oauth' && f.type !== 'ssh_keypair').map((field) => (
265
+ <div key={field.key} className="space-y-1.5">
266
+ <label className="text-sm font-medium">
267
+ {field.label}{field.required && <span className="text-red-500 ml-0.5">*</span>}
268
+ </label>
269
+ {field.type === 'select' && field.options ? (
270
+ <select
271
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
272
+ value={(credValues[field.key] as string) ?? ''}
273
+ onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
274
+ >
275
+ <option value="">—</option>
276
+ {field.options.map((opt) => (
277
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
278
+ ))}
279
+ </select>
280
+ ) : field.type === 'boolean' ? (
281
+ <Switch
282
+ checked={Boolean(credValues[field.key])}
283
+ onCheckedChange={(checked) => setCredValues((prev) => ({ ...prev, [field.key]: checked }))}
284
+ />
285
+ ) : (
286
+ <Input
287
+ type={field.type === 'secret' ? 'password' : 'text'}
288
+ placeholder={field.placeholder}
289
+ value={(credValues[field.key] as string) ?? ''}
290
+ onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
291
+ />
292
+ )}
293
+ </div>
294
+ ))}
295
+ <Button type="button" onClick={() => void handleSaveCredentials()} disabled={isSavingCreds}>
296
+ {isSavingCreds ? <Spinner className="mr-2 h-4 w-4" /> : null}
297
+ {t('integrations.detail.credentials.save')}
298
+ </Button>
299
+ </CardContent>
300
+ </Card>
301
+ )}
302
+ </TabsContent>
303
+
304
+ {hasVersions && (
305
+ <TabsContent value="version" className="space-y-4 mt-4">
306
+ <Card>
307
+ <CardHeader>
308
+ <CardTitle>{t('integrations.detail.version.select')}</CardTitle>
309
+ </CardHeader>
310
+ <CardContent className="space-y-3">
311
+ {integration.apiVersions!.map((v) => {
312
+ const isSelected = (state.apiVersion ?? integration.apiVersions!.find((x) => x.status === 'stable')?.id) === v.id
313
+ return (
314
+ <div
315
+ key={v.id}
316
+ className={`flex items-center justify-between rounded-lg border p-3 cursor-pointer transition-colors ${isSelected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
317
+ onClick={() => void handleVersionChange(v.id)}
318
+ >
319
+ <div>
320
+ <span className="font-medium text-sm">{v.label ?? v.id}</span>
321
+ <Badge
322
+ variant={v.status === 'stable' ? 'default' : v.status === 'deprecated' ? 'destructive' : 'secondary'}
323
+ className="ml-2"
324
+ >
325
+ {t(`integrations.detail.version.${v.status}`)}
326
+ </Badge>
327
+ {v.status === 'deprecated' && v.sunsetAt && (
328
+ <span className="text-xs text-muted-foreground ml-2">
329
+ {t('integrations.detail.version.sunsetAt', { date: new Date(v.sunsetAt).toLocaleDateString() })}
330
+ </span>
331
+ )}
332
+ </div>
333
+ {isSelected && <Badge variant="outline">{t('integrations.detail.version.current')}</Badge>}
334
+ </div>
335
+ )
336
+ })}
337
+ </CardContent>
338
+ </Card>
339
+ </TabsContent>
340
+ )}
341
+
342
+ <TabsContent value="health" className="space-y-4 mt-4">
343
+ <Card>
344
+ <CardHeader>
345
+ <div className="flex items-center justify-between">
346
+ <CardTitle>{t('integrations.detail.health.title')}</CardTitle>
347
+ <Button type="button" variant="outline" size="sm" onClick={() => void handleHealthCheck()} disabled={isCheckingHealth}>
348
+ {isCheckingHealth ? <Spinner className="mr-2 h-4 w-4" /> : null}
349
+ {isCheckingHealth ? t('integrations.detail.health.checking') : t('integrations.detail.health.check')}
350
+ </Button>
351
+ </div>
352
+ </CardHeader>
353
+ <CardContent className="space-y-3">
354
+ <div className="flex items-center gap-3">
355
+ <span className="text-sm font-medium">{t('integrations.detail.health.title')}:</span>
356
+ {state.lastHealthStatus ? (
357
+ <Badge className={HEALTH_STATUS_STYLES[state.lastHealthStatus] ?? ''}>
358
+ {t(`integrations.detail.health.${state.lastHealthStatus}`)}
359
+ </Badge>
360
+ ) : (
361
+ <span className="text-sm text-muted-foreground">{t('integrations.detail.health.unknown')}</span>
362
+ )}
363
+ </div>
364
+ <p className="text-xs text-muted-foreground">
365
+ {state.lastHealthCheckedAt
366
+ ? t('integrations.detail.health.lastChecked', { date: new Date(state.lastHealthCheckedAt).toLocaleString() })
367
+ : t('integrations.detail.health.neverChecked')
368
+ }
369
+ </p>
370
+ </CardContent>
371
+ </Card>
372
+ </TabsContent>
373
+
374
+ <TabsContent value="logs" className="space-y-4 mt-4">
375
+ <div className="flex items-center gap-3">
376
+ <select
377
+ className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm"
378
+ value={logLevel}
379
+ onChange={(e) => setLogLevel(e.target.value)}
380
+ >
381
+ <option value="">{t('integrations.detail.logs.level.all')}</option>
382
+ <option value="info">{t('integrations.detail.logs.level.info')}</option>
383
+ <option value="warn">{t('integrations.detail.logs.level.warn')}</option>
384
+ <option value="error">{t('integrations.detail.logs.level.error')}</option>
385
+ </select>
386
+ </div>
387
+ {isLoadingLogs ? (
388
+ <div className="flex justify-center py-8"><Spinner /></div>
389
+ ) : logs.length === 0 ? (
390
+ <p className="text-muted-foreground text-sm py-4">{t('integrations.detail.logs.empty')}</p>
391
+ ) : (
392
+ <div className="rounded-lg border">
393
+ <table className="w-full text-sm">
394
+ <thead>
395
+ <tr className="border-b bg-muted/50">
396
+ <th className="px-4 py-2 text-left font-medium">{t('integrations.detail.logs.columns.time')}</th>
397
+ <th className="px-4 py-2 text-left font-medium">{t('integrations.detail.logs.columns.level')}</th>
398
+ <th className="px-4 py-2 text-left font-medium">{t('integrations.detail.logs.columns.message')}</th>
399
+ </tr>
400
+ </thead>
401
+ <tbody>
402
+ {logs.map((log) => (
403
+ <tr key={log.id} className="border-b last:border-0">
404
+ <td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
405
+ {new Date(log.createdAt).toLocaleString()}
406
+ </td>
407
+ <td className="px-4 py-2">
408
+ <Badge variant="secondary" className={LOG_LEVEL_STYLES[log.level] ?? ''}>
409
+ {log.level}
410
+ </Badge>
411
+ </td>
412
+ <td className="px-4 py-2">{log.message}</td>
413
+ </tr>
414
+ ))}
415
+ </tbody>
416
+ </table>
417
+ </div>
418
+ )}
419
+ </TabsContent>
420
+ </Tabs>
421
+ </PageBody>
422
+ </Page>
423
+ )
424
+ }
@@ -0,0 +1,11 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['integrations.view'],
4
+ pageContext: 'settings' as const,
5
+ pageTitle: 'Bundle Configuration',
6
+ pageTitleKey: 'integrations.bundle.title',
7
+ navHidden: true,
8
+ breadcrumb: [
9
+ { label: 'Integrations', labelKey: 'integrations.nav.title', href: '/backend/integrations' },
10
+ ],
11
+ }
@@ -0,0 +1,249 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { useParams } from 'next/navigation'
5
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import { Card, CardHeader, CardTitle, CardContent } from '@open-mercato/ui/primitives/card'
7
+ import { Badge } from '@open-mercato/ui/primitives/badge'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Switch } from '@open-mercato/ui/primitives/switch'
10
+ import { Input } from '@open-mercato/ui/primitives/input'
11
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
13
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
14
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
+ import type { IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
16
+ import { LoadingMessage } from '@open-mercato/ui/backend/detail'
17
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
18
+
19
+ type CredentialField = IntegrationCredentialField
20
+
21
+ type BundleIntegration = {
22
+ id: string
23
+ title: string
24
+ description?: string
25
+ category?: string
26
+ isEnabled: boolean
27
+ }
28
+
29
+ type BundleDetail = {
30
+ integration: {
31
+ id: string
32
+ title: string
33
+ description?: string
34
+ bundleId?: string
35
+ }
36
+ bundle?: {
37
+ id: string
38
+ title: string
39
+ description?: string
40
+ credentials?: { fields: CredentialField[] }
41
+ }
42
+ bundleIntegrations: BundleIntegration[]
43
+ state: { isEnabled: boolean }
44
+ hasCredentials: boolean
45
+ }
46
+
47
+ export default function BundleConfigPage() {
48
+ const params = useParams<{ id: string }>()
49
+ const bundleId = params.id
50
+ const t = useT()
51
+
52
+ const [detail, setDetail] = React.useState<BundleDetail | null>(null)
53
+ const [isLoading, setIsLoading] = React.useState(true)
54
+ const [error, setError] = React.useState<string | null>(null)
55
+ const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
56
+ const [isSavingCreds, setIsSavingCreds] = React.useState(false)
57
+ const [togglingIds, setTogglingIds] = React.useState<Set<string>>(new Set())
58
+
59
+ const load = React.useCallback(async () => {
60
+ setIsLoading(true)
61
+ setError(null)
62
+ const call = await apiCall<BundleDetail>(
63
+ `/api/integrations/${encodeURIComponent(bundleId)}`,
64
+ undefined,
65
+ { fallback: null },
66
+ )
67
+ if (!call.ok || !call.result) {
68
+ setError(t('integrations.detail.loadError'))
69
+ setIsLoading(false)
70
+ return
71
+ }
72
+ setDetail(call.result)
73
+
74
+ const credCall = await apiCall<{ credentials: Record<string, unknown> }>(
75
+ `/api/integrations/${encodeURIComponent(bundleId)}/credentials`,
76
+ undefined,
77
+ { fallback: null },
78
+ )
79
+ if (credCall.ok && credCall.result?.credentials) {
80
+ setCredValues(credCall.result.credentials)
81
+ }
82
+ setIsLoading(false)
83
+ }, [bundleId, t])
84
+
85
+ React.useEffect(() => { void load() }, [load])
86
+
87
+ const handleSaveCredentials = React.useCallback(async () => {
88
+ setIsSavingCreds(true)
89
+ const call = await apiCall(`/api/integrations/${encodeURIComponent(bundleId)}/credentials`, {
90
+ method: 'PUT',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({ credentials: credValues }),
93
+ }, { fallback: null })
94
+ if (call.ok) {
95
+ flash(t('integrations.detail.credentials.saved'), 'success')
96
+ } else {
97
+ flash(t('integrations.detail.credentials.saveError'), 'error')
98
+ }
99
+ setIsSavingCreds(false)
100
+ }, [bundleId, credValues, t])
101
+
102
+ const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean) => {
103
+ setTogglingIds((prev) => new Set(prev).add(integrationId))
104
+ const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
105
+ method: 'PUT',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ isEnabled: enabled }),
108
+ }, { fallback: null })
109
+ if (call.ok) {
110
+ setDetail((prev) => {
111
+ if (!prev) return prev
112
+ return {
113
+ ...prev,
114
+ bundleIntegrations: prev.bundleIntegrations.map((item) =>
115
+ item.id === integrationId ? { ...item, isEnabled: enabled } : item,
116
+ ),
117
+ }
118
+ })
119
+ } else {
120
+ flash(t('integrations.detail.stateError'), 'error')
121
+ }
122
+ setTogglingIds((prev) => { const next = new Set(prev); next.delete(integrationId); return next })
123
+ }, [t])
124
+
125
+ const handleBulkToggle = React.useCallback(async (enabled: boolean) => {
126
+ if (!detail) return
127
+ const targets = detail.bundleIntegrations.filter((item) => item.isEnabled !== enabled)
128
+ await Promise.all(targets.map((item) => handleToggle(item.id, enabled)))
129
+ }, [detail, handleToggle])
130
+
131
+ if (isLoading) return <Page><PageBody><LoadingMessage label={t('integrations.bundle.title')} /></PageBody></Page>
132
+ if (error || !detail?.bundle) return <Page><PageBody><ErrorMessage label={error ?? t('integrations.detail.loadError')} /></PageBody></Page>
133
+
134
+ const credFields = detail.bundle.credentials?.fields ?? []
135
+
136
+ return (
137
+ <Page>
138
+ <PageBody className="space-y-6">
139
+ <div>
140
+ <Link href="/backend/integrations" className="text-sm text-muted-foreground hover:underline">
141
+ {t('integrations.detail.back')}
142
+ </Link>
143
+ </div>
144
+
145
+ <div>
146
+ <h1 className="text-2xl font-semibold">{detail.bundle.title}</h1>
147
+ {detail.bundle.description && (
148
+ <p className="text-muted-foreground mt-1">{detail.bundle.description}</p>
149
+ )}
150
+ </div>
151
+
152
+ {credFields.length > 0 && (
153
+ <Card>
154
+ <CardHeader>
155
+ <CardTitle>{t('integrations.bundle.sharedCredentials')}</CardTitle>
156
+ </CardHeader>
157
+ <CardContent className="space-y-4">
158
+ {credFields.map((field) => (
159
+ <div key={field.key} className="space-y-1.5">
160
+ <label className="text-sm font-medium">
161
+ {field.label}{field.required && <span className="text-red-500 ml-0.5">*</span>}
162
+ </label>
163
+ {field.type === 'select' && field.options ? (
164
+ <select
165
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
166
+ value={(credValues[field.key] as string) ?? ''}
167
+ onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
168
+ >
169
+ <option value="">—</option>
170
+ {field.options.map((opt) => (
171
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
172
+ ))}
173
+ </select>
174
+ ) : field.type === 'boolean' ? (
175
+ <Switch
176
+ checked={Boolean(credValues[field.key])}
177
+ onCheckedChange={(checked) => setCredValues((prev) => ({ ...prev, [field.key]: checked }))}
178
+ />
179
+ ) : (
180
+ <Input
181
+ type={field.type === 'secret' ? 'password' : 'text'}
182
+ placeholder={field.placeholder}
183
+ value={(credValues[field.key] as string) ?? ''}
184
+ onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
185
+ />
186
+ )}
187
+ </div>
188
+ ))}
189
+ <Button type="button" onClick={() => void handleSaveCredentials()} disabled={isSavingCreds}>
190
+ {isSavingCreds ? <Spinner className="mr-2 h-4 w-4" /> : null}
191
+ {t('integrations.detail.credentials.save')}
192
+ </Button>
193
+ </CardContent>
194
+ </Card>
195
+ )}
196
+
197
+ <Card>
198
+ <CardHeader>
199
+ <div className="flex items-center justify-between">
200
+ <CardTitle>{t('integrations.bundle.integrationToggles')}</CardTitle>
201
+ <div className="flex gap-2">
202
+ <Button type="button" variant="outline" size="sm" onClick={() => void handleBulkToggle(true)}>
203
+ {t('integrations.marketplace.enableAll')}
204
+ </Button>
205
+ <Button type="button" variant="outline" size="sm" onClick={() => void handleBulkToggle(false)}>
206
+ {t('integrations.marketplace.disableAll')}
207
+ </Button>
208
+ </div>
209
+ </div>
210
+ </CardHeader>
211
+ <CardContent>
212
+ <div className="space-y-3">
213
+ {detail.bundleIntegrations.map((item) => (
214
+ <div key={item.id} className="flex items-center justify-between rounded-lg border p-3">
215
+ <div>
216
+ <Link
217
+ href={`/backend/integrations/${encodeURIComponent(item.id)}`}
218
+ className="text-sm font-medium hover:underline"
219
+ >
220
+ {item.title}
221
+ </Link>
222
+ {item.category && (
223
+ <Badge variant="secondary" className="ml-2 text-xs">{item.category}</Badge>
224
+ )}
225
+ {item.description && (
226
+ <p className="text-xs text-muted-foreground mt-0.5">{item.description}</p>
227
+ )}
228
+ </div>
229
+ <div className="flex items-center gap-3">
230
+ <Button asChild variant="ghost" size="sm">
231
+ <Link href={`/backend/integrations/${encodeURIComponent(item.id)}`}>
232
+ {t('integrations.bundle.configureIntegration')}
233
+ </Link>
234
+ </Button>
235
+ <Switch
236
+ checked={item.isEnabled}
237
+ disabled={togglingIds.has(item.id)}
238
+ onCheckedChange={(checked) => void handleToggle(item.id, checked)}
239
+ />
240
+ </div>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ </CardContent>
245
+ </Card>
246
+ </PageBody>
247
+ </Page>
248
+ )
249
+ }