@hed-hog/core 0.0.400 → 0.0.402

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 (274) hide show
  1. package/dist/access-log/access-log.interceptor.d.ts +10 -0
  2. package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
  3. package/dist/access-log/access-log.interceptor.js +15 -1
  4. package/dist/access-log/access-log.interceptor.js.map +1 -1
  5. package/dist/ai/ai.controller.d.ts +2 -0
  6. package/dist/ai/ai.controller.d.ts.map +1 -1
  7. package/dist/ai/ai.service.d.ts +86 -2
  8. package/dist/ai/ai.service.d.ts.map +1 -1
  9. package/dist/ai/ai.service.js +304 -23
  10. package/dist/ai/ai.service.js.map +1 -1
  11. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  12. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  13. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  14. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  15. package/dist/core.module.d.ts.map +1 -1
  16. package/dist/core.module.js +3 -0
  17. package/dist/core.module.js.map +1 -1
  18. package/dist/document-kit/document-kit.module.d.ts +3 -0
  19. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.module.js +24 -0
  21. package/dist/document-kit/document-kit.module.js.map +1 -0
  22. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  23. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.registry.js +91 -0
  25. package/dist/document-kit/document-kit.registry.js.map +1 -0
  26. package/dist/document-kit/document-kit.service.d.ts +49 -0
  27. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  28. package/dist/document-kit/document-kit.service.js +135 -0
  29. package/dist/document-kit/document-kit.service.js.map +1 -0
  30. package/dist/file/file-upload-ticket.service.d.ts +1 -1
  31. package/dist/file/file.controller.d.ts +3 -3
  32. package/dist/file/file.service.d.ts +136 -13
  33. package/dist/file/file.service.d.ts.map +1 -1
  34. package/dist/file/file.service.js +297 -27
  35. package/dist/file/file.service.js.map +1 -1
  36. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  37. package/dist/file/mcp-file-source.schema.js +8 -5
  38. package/dist/file/mcp-file-source.schema.js.map +1 -1
  39. package/dist/file/provider/abstract.provider.d.ts +33 -0
  40. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  41. package/dist/file/provider/abstract.provider.js +9 -0
  42. package/dist/file/provider/abstract.provider.js.map +1 -1
  43. package/dist/file/provider/s3-multipart.util.d.ts +17 -0
  44. package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
  45. package/dist/file/provider/s3-multipart.util.js +88 -0
  46. package/dist/file/provider/s3-multipart.util.js.map +1 -0
  47. package/dist/file/provider/s3.provider.d.ts +9 -1
  48. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  49. package/dist/file/provider/s3.provider.js +16 -0
  50. package/dist/file/provider/s3.provider.js.map +1 -1
  51. package/dist/file/provider/spaces.provider.d.ts +10 -1
  52. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  53. package/dist/file/provider/spaces.provider.js +17 -0
  54. package/dist/file/provider/spaces.provider.js.map +1 -1
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +5 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
  60. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  61. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  62. package/dist/integration-profile/integration-profile.secrets.js +26 -0
  63. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  64. package/dist/integration-profile/integration-profile.service.d.ts +1 -0
  65. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  66. package/dist/integration-profile/integration-profile.service.js +3 -3
  67. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  68. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  69. package/dist/integration-profile/testers/ai.testers.js +46 -8
  70. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  71. package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
  72. package/dist/integration-profile/testers/http-probe.js +5 -3
  73. package/dist/integration-profile/testers/http-probe.js.map +1 -1
  74. package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
  75. package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
  76. package/dist/mail/mail.service.d.ts.map +1 -1
  77. package/dist/mail/mail.service.js +16 -1
  78. package/dist/mail/mail.service.js.map +1 -1
  79. package/dist/mcp/mcp-server.service.d.ts.map +1 -1
  80. package/dist/mcp/mcp-server.service.js +10 -1
  81. package/dist/mcp/mcp-server.service.js.map +1 -1
  82. package/dist/mcp/mcp.module.d.ts.map +1 -1
  83. package/dist/mcp/mcp.module.js +2 -0
  84. package/dist/mcp/mcp.module.js.map +1 -1
  85. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  86. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  87. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  88. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  89. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  90. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  91. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  92. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  93. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  94. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  95. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  96. package/dist/oauth/oauth-token-broker.service.js +105 -3
  97. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  98. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  99. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  100. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  101. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  102. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  103. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  104. package/dist/pdf/document/charts/chart-bar.js +117 -0
  105. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  106. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  107. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  108. package/dist/pdf/document/charts/chart-donut.js +89 -0
  109. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  110. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  111. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  112. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  113. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  114. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  115. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  116. package/dist/pdf/document/charts/chart-line.js +89 -0
  117. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  118. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  119. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  120. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  121. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  122. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  123. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  124. package/dist/pdf/document/charts/chart-radar.js +83 -0
  125. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  126. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  127. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  128. package/dist/pdf/document/charts/chart-scale.js +118 -0
  129. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  130. package/dist/pdf/document/charts/index.d.ts +8 -0
  131. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  132. package/dist/pdf/document/charts/index.js +24 -0
  133. package/dist/pdf/document/charts/index.js.map +1 -0
  134. package/dist/pdf/document/index.d.ts +8 -0
  135. package/dist/pdf/document/index.d.ts.map +1 -0
  136. package/dist/pdf/document/index.js +24 -0
  137. package/dist/pdf/document/index.js.map +1 -0
  138. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  139. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  140. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  141. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  142. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  143. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  144. package/dist/pdf/document/pdf-document.css.js +327 -0
  145. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  146. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  147. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  148. package/dist/pdf/document/pdf-document.renderer.js +226 -0
  149. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  150. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  151. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  152. package/dist/pdf/document/pdf-document.service.js +81 -0
  153. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  154. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  155. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  156. package/dist/pdf/document/pdf-document.theme.js +105 -0
  157. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  158. package/dist/pdf/document/pdf-document.types.d.ts +164 -0
  159. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  160. package/dist/pdf/document/pdf-document.types.js +18 -0
  161. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  162. package/dist/pdf/pdf.module.d.ts.map +1 -1
  163. package/dist/pdf/pdf.module.js +3 -2
  164. package/dist/pdf/pdf.module.js.map +1 -1
  165. package/dist/pdf/pdf.service.d.ts +75 -1
  166. package/dist/pdf/pdf.service.d.ts.map +1 -1
  167. package/dist/pdf/pdf.service.js +167 -63
  168. package/dist/pdf/pdf.service.js.map +1 -1
  169. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  170. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  171. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  172. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  173. package/dist/setting/favicon.controller.d.ts +26 -0
  174. package/dist/setting/favicon.controller.d.ts.map +1 -0
  175. package/dist/setting/favicon.controller.js +130 -0
  176. package/dist/setting/favicon.controller.js.map +1 -0
  177. package/dist/setting/setting.module.d.ts.map +1 -1
  178. package/dist/setting/setting.module.js +2 -1
  179. package/dist/setting/setting.module.js.map +1 -1
  180. package/dist/setting/setting.service.d.ts.map +1 -1
  181. package/dist/setting/setting.service.js +3 -0
  182. package/dist/setting/setting.service.js.map +1 -1
  183. package/dist/task/task.service.d.ts +6 -0
  184. package/dist/task/task.service.d.ts.map +1 -1
  185. package/dist/task/task.service.js +44 -13
  186. package/dist/task/task.service.js.map +1 -1
  187. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
  188. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  189. package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
  190. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  191. package/hedhog/data/integration_type.yaml +6 -0
  192. package/hedhog/data/route.yaml +8 -0
  193. package/hedhog/data/setting_group.yaml +52 -0
  194. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
  195. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
  196. package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
  197. package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
  198. package/hedhog/frontend/messages/en.json +81 -2
  199. package/hedhog/frontend/messages/pt.json +81 -2
  200. package/hedhog/table/file.yaml +3 -1
  201. package/package.json +5 -5
  202. package/src/access-log/access-log.interceptor.spec.ts +50 -0
  203. package/src/access-log/access-log.interceptor.ts +18 -0
  204. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  205. package/src/ai/ai.service.responses.spec.ts +429 -0
  206. package/src/ai/ai.service.ts +409 -27
  207. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  208. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  209. package/src/core.module.ts +3 -0
  210. package/src/document-kit/document-kit.module.ts +12 -0
  211. package/src/document-kit/document-kit.registry.ts +125 -0
  212. package/src/document-kit/document-kit.service.spec.ts +156 -0
  213. package/src/document-kit/document-kit.service.ts +173 -0
  214. package/src/file/file-direct-upload.spec.ts +344 -0
  215. package/src/file/file.service.spec.ts +168 -1
  216. package/src/file/file.service.ts +435 -36
  217. package/src/file/mcp-file-source.schema.ts +8 -5
  218. package/src/file/provider/abstract.provider.ts +57 -0
  219. package/src/file/provider/s3-multipart.spec.ts +123 -0
  220. package/src/file/provider/s3-multipart.util.ts +126 -0
  221. package/src/file/provider/s3.provider.ts +65 -1
  222. package/src/file/provider/spaces.provider.ts +67 -1
  223. package/src/index.ts +5 -0
  224. package/src/integration-profile/integration-profile.secrets.ts +26 -0
  225. package/src/integration-profile/integration-profile.service.ts +1 -0
  226. package/src/integration-profile/testers/ai.testers.ts +70 -5
  227. package/src/integration-profile/testers/http-probe.ts +5 -3
  228. package/src/integration-profile/testers/integration-tester.types.ts +5 -0
  229. package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
  230. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  231. package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
  232. package/src/mail/mail.service.spec.ts +25 -0
  233. package/src/mail/mail.service.ts +18 -1
  234. package/src/mcp/mcp-server.service.spec.ts +16 -0
  235. package/src/mcp/mcp-server.service.ts +10 -1
  236. package/src/mcp/mcp.module.ts +2 -0
  237. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  238. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  239. package/src/meeting/meeting-provider.registry.ts +80 -0
  240. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  241. package/src/oauth/oauth-token-broker.service.ts +129 -2
  242. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  243. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  244. package/src/pdf/document/charts/chart-bar.ts +217 -0
  245. package/src/pdf/document/charts/chart-donut.ts +163 -0
  246. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  247. package/src/pdf/document/charts/chart-line.ts +159 -0
  248. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  249. package/src/pdf/document/charts/chart-radar.ts +136 -0
  250. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  251. package/src/pdf/document/charts/chart-scale.ts +141 -0
  252. package/src/pdf/document/charts/charts.spec.ts +157 -0
  253. package/src/pdf/document/charts/index.ts +7 -0
  254. package/src/pdf/document/index.ts +7 -0
  255. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  256. package/src/pdf/document/pdf-document.css.ts +326 -0
  257. package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
  258. package/src/pdf/document/pdf-document.renderer.ts +317 -0
  259. package/src/pdf/document/pdf-document.service.ts +77 -0
  260. package/src/pdf/document/pdf-document.theme.ts +146 -0
  261. package/src/pdf/document/pdf-document.types.ts +149 -0
  262. package/src/pdf/pdf.module.ts +3 -2
  263. package/src/pdf/pdf.service.spec.ts +131 -0
  264. package/src/pdf/pdf.service.ts +256 -82
  265. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  266. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  267. package/src/setting/favicon.controller.spec.ts +92 -0
  268. package/src/setting/favicon.controller.ts +135 -0
  269. package/src/setting/setting.module.ts +2 -1
  270. package/src/setting/setting.service.ts +3 -0
  271. package/src/task/task.service.spec.ts +77 -1
  272. package/src/task/task.service.ts +58 -14
  273. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
  274. package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
@@ -225,6 +225,13 @@ export const SettingField = ({ setting, className }: SettingFieldProps) => {
225
225
  const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
226
226
  const isTextInputRef = useRef<boolean>(false);
227
227
  const fileInputRef = useRef<HTMLInputElement>(null);
228
+ /** Envolve os campos digitados: saber se o foco está neles decide quando salvar. */
229
+ const fieldRef = useRef<HTMLDivElement>(null);
230
+ /** O último valor gravado (o `setting.value` da prop não muda depois de salvar). */
231
+ const savedValueRef = useRef(setting.value);
232
+ /** O valor em edição, para salvar no blur e na desmontagem sem esperar render. */
233
+ const localValueRef = useRef(localValue);
234
+ localValueRef.current = localValue;
228
235
 
229
236
  const isThemeSetting = (slug: string) => {
230
237
  return slug.startsWith('theme-') || slug === 'menu-width';
@@ -245,34 +252,50 @@ export const SettingField = ({ setting, className }: SettingFieldProps) => {
245
252
  }
246
253
  };
247
254
 
248
- useEffect(() => {
249
- if (!isTextInputRef.current) return;
250
-
255
+ const saveValue = (value: typeof setting.value) => {
251
256
  if (debounceTimerRef.current) {
252
257
  clearTimeout(debounceTimerRef.current);
258
+ debounceTimerRef.current = undefined;
253
259
  }
260
+ if (value === savedValueRef.current) return;
261
+
262
+ const previous = savedValueRef.current;
263
+ savedValueRef.current = value;
264
+ setSettingValue(setting.slug, value)
265
+ .then(async () => {
266
+ showToastHandler('success', t('settingUpdated'));
267
+
268
+ if (['icon-url'].includes(setting.slug)) {
269
+ await revalidateSystemIcon();
270
+ showToastHandler('success', t('iconRevalidated'));
271
+ window.location.reload();
272
+ }
254
273
 
255
- debounceTimerRef.current = setTimeout(() => {
256
- if (localValue !== setting.value) {
257
- setSettingValue(setting.slug, localValue)
258
- .then(async () => {
259
- showToastHandler('success', t('settingUpdated'));
274
+ if (isThemeSetting(setting.slug)) {
275
+ await applyThemeSettings();
276
+ }
277
+ })
278
+ .catch(() => {
279
+ savedValueRef.current = previous;
280
+ showToastHandler('error', t('settingUpdateFailed'));
281
+ });
282
+ };
260
283
 
261
- if (['icon-url'].includes(setting.slug)) {
262
- await revalidateSystemIcon();
263
- showToastHandler('success', t('iconRevalidated'));
264
- window.location.reload();
265
- }
284
+ /**
285
+ * Salvamento automático dos campos digitados. Enquanto o foco está no campo,
286
+ * nada é gravado: salvar a cada pausa gravava o valor pela metade (digitando
287
+ * "10" GB, o "1" ia para o banco). Grava ao sair do campo, no Enter e ao
288
+ * desmontar (trocar de aba). Mudança sem foco no campo — arquivo enviado,
289
+ * perfil aplicado — segue com o debounce de 500 ms.
290
+ */
291
+ useEffect(() => {
292
+ if (!isTextInputRef.current) return;
293
+ if (fieldRef.current?.contains(document.activeElement)) return;
266
294
 
267
- if (isThemeSetting(setting.slug)) {
268
- await applyThemeSettings();
269
- }
270
- })
271
- .catch(() => {
272
- showToastHandler('error', t('settingUpdateFailed'));
273
- });
274
- }
275
- }, 500);
295
+ if (debounceTimerRef.current) {
296
+ clearTimeout(debounceTimerRef.current);
297
+ }
298
+ debounceTimerRef.current = setTimeout(() => saveValue(localValue), 500);
276
299
 
277
300
  return () => {
278
301
  if (debounceTimerRef.current) {
@@ -281,6 +304,26 @@ export const SettingField = ({ setting, className }: SettingFieldProps) => {
281
304
  };
282
305
  }, [localValue]);
283
306
 
307
+ // Desmontar com edição pendente (trocar de aba, sair da tela) grava o valor,
308
+ // em vez de descartá-lo junto com o timer.
309
+ useEffect(() => {
310
+ return () => {
311
+ if (isTextInputRef.current) saveValue(localValueRef.current);
312
+ };
313
+ }, []);
314
+
315
+ const handleFieldBlur = (event: React.FocusEvent<HTMLDivElement>) => {
316
+ const next = event.relatedTarget as Node | null;
317
+ if (next && event.currentTarget.contains(next)) return;
318
+ saveValue(localValueRef.current);
319
+ };
320
+
321
+ const handleFieldKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
322
+ if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
323
+ saveValue(localValueRef.current);
324
+ }
325
+ };
326
+
284
327
  // Booleano em vez de `setting` na lista de dependências: o objeto da setting
285
328
  // troca de identidade a cada render do pai e dispararia a resolução do rótulo
286
329
  // de novo a cada vez.
@@ -332,7 +375,9 @@ export const SettingField = ({ setting, className }: SettingFieldProps) => {
332
375
 
333
376
  const handleChangeValue = (newValue: string, immediate = false) => {
334
377
  setLocalValue(newValue);
378
+ localValueRef.current = newValue;
335
379
  if (immediate) {
380
+ savedValueRef.current = newValue;
336
381
  setSettingValue(setting.slug, newValue)
337
382
  .then(async () => {
338
383
  showToastHandler('success', t('settingUpdated'));
@@ -384,518 +429,558 @@ export const SettingField = ({ setting, className }: SettingFieldProps) => {
384
429
  };
385
430
 
386
431
  const parsedValue = getParsedValue();
387
- switch (component) {
388
- case SettingComponentEnum.INPUT_TEXT:
389
- isTextInputRef.current = true;
390
- return (
391
- <Input
392
- type="text"
393
- value={parsedValue || ''}
394
- onChange={(e) => handleChangeValue(formatValue(e.target.value))}
395
- className={cn(className, 'w-full min-w-0 bg-background md:min-w-50')}
396
- />
397
- );
398
-
399
- case SettingComponentEnum.INPUT_NUMBER:
400
- isTextInputRef.current = true;
401
- return (
402
- <Input
403
- type="number"
404
- value={parsedValue || 0}
405
- onChange={(e) =>
406
- handleChangeValue(formatValue(parseFloat(e.target.value) || 0))
407
- }
408
- className={cn(className, 'w-full min-w-0 bg-background md:min-w-50')}
409
- />
410
- );
411
-
412
- case SettingComponentEnum.INPUT_DURATION:
413
- isTextInputRef.current = true; // reaproveita o debounce de 500ms existente
414
- return (
415
- <DurationInput
416
- value={
417
- typeof parsedValue === 'number'
418
- ? parsedValue
419
- : parseFloat(parsedValue) || 0
420
- }
421
- onChange={(seconds) => handleChangeValue(formatValue(seconds))}
422
- persistKey={setting.slug}
423
- className={cn(className, 'w-full min-w-0 bg-background md:min-w-50')}
424
- />
425
- );
426
-
427
- case SettingComponentEnum.INPUT_BYTES:
428
- isTextInputRef.current = true; // reaproveita o debounce de 500ms existente
429
- return (
430
- <ByteSizeInput
431
- value={
432
- typeof parsedValue === 'number'
433
- ? parsedValue
434
- : parseFloat(parsedValue) || 0
435
- }
436
- onChange={(bytes) => handleChangeValue(formatValue(bytes))}
437
- persistKey={setting.slug}
438
- className={cn(className, 'w-full min-w-0 bg-background md:min-w-50')}
439
- />
440
- );
441
-
442
- case SettingComponentEnum.INPUT_SECRET:
443
- isTextInputRef.current = true;
444
- return (
445
- <div className="relative w-full min-w-0 md:min-w-50">
432
+ isTextInputRef.current = false;
433
+ const control = renderControl();
434
+
435
+ if (!isTextInputRef.current) return control;
436
+
437
+ return (
438
+ <div
439
+ ref={fieldRef}
440
+ className="contents"
441
+ onBlur={handleFieldBlur}
442
+ onKeyDown={handleFieldKeyDown}
443
+ >
444
+ {control}
445
+ </div>
446
+ );
447
+
448
+ function renderControl() {
449
+ switch (component) {
450
+ case SettingComponentEnum.INPUT_TEXT:
451
+ isTextInputRef.current = true;
452
+ return (
446
453
  <Input
447
- type={showSecretValue ? 'text' : 'password'}
454
+ type="text"
448
455
  value={parsedValue || ''}
449
456
  onChange={(e) => handleChangeValue(formatValue(e.target.value))}
450
457
  className={cn(
451
458
  className,
452
- 'w-full min-w-0 bg-background pr-10 md:min-w-50'
459
+ 'w-full min-w-0 bg-background md:min-w-50'
453
460
  )}
454
461
  />
455
- <Button
456
- type="button"
457
- variant="ghost"
458
- size="icon"
459
- className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
460
- onClick={() => setShowSecretValue((prev) => !prev)}
461
- tooltip={showSecretValue ? t('hidePassword') : t('showPassword')}
462
- >
463
- {showSecretValue ? (
464
- <EyeOff className="h-4 w-4" />
465
- ) : (
466
- <Eye className="h-4 w-4" />
467
- )}
468
- </Button>
469
- </div>
470
- );
471
-
472
- case SettingComponentEnum.INPUT_FILE:
473
- isTextInputRef.current = true;
474
-
475
- const handleFileUpload = async (
476
- e: React.ChangeEvent<HTMLInputElement>
477
- ) => {
478
- const file = e.target.files?.[0];
479
- if (!file) return;
462
+ );
480
463
 
481
- setIsUploading(true);
482
- try {
483
- const formData = new FormData();
484
- formData.append('file', file);
485
-
486
- const response = await request<{ id?: number | string }>({
487
- url: '/file',
488
- method: 'POST',
489
- data: formData,
490
- headers: {
491
- 'Content-Type': 'multipart/form-data',
492
- },
493
- });
494
-
495
- const fileId = Number(response.data?.id);
496
-
497
- if (!fileId) {
498
- throw new Error(t('fileIdNotReturned'));
499
- }
464
+ case SettingComponentEnum.INPUT_NUMBER:
465
+ isTextInputRef.current = true;
466
+ return (
467
+ <Input
468
+ type="number"
469
+ value={parsedValue || 0}
470
+ onChange={(e) =>
471
+ handleChangeValue(formatValue(parseFloat(e.target.value) || 0))
472
+ }
473
+ className={cn(
474
+ className,
475
+ 'w-full min-w-0 bg-background md:min-w-50'
476
+ )}
477
+ />
478
+ );
500
479
 
501
- // `/file/image/:id` é público, imutável (Cache-Control + ETag) e não
502
- // expira. A URL de `PUT /file/open/:id`, gravada aqui antes, carrega
503
- // um JWT de 3600s: a setting virava link morto uma hora depois do
504
- // upload. Absoluta de propósito — o mesmo valor é lido pelo <img> da
505
- // sidebar, pelo favicon dinâmico, pelo metadata do layout e pelo
506
- // e-mail transacional, que roda fora do browser.
507
- const fileUrl = buildAbsoluteImageUrl(fileId);
480
+ case SettingComponentEnum.INPUT_DURATION:
481
+ isTextInputRef.current = true; // reaproveita o debounce de 500ms existente
482
+ return (
483
+ <DurationInput
484
+ value={
485
+ typeof parsedValue === 'number'
486
+ ? parsedValue
487
+ : parseFloat(parsedValue) || 0
488
+ }
489
+ onChange={(seconds) => handleChangeValue(formatValue(seconds))}
490
+ persistKey={setting.slug}
491
+ className={cn(
492
+ className,
493
+ 'w-full min-w-0 bg-background md:min-w-50'
494
+ )}
495
+ />
496
+ );
508
497
 
509
- if (!fileUrl) {
510
- throw new Error(t('apiBaseUrlNotConfigured'));
511
- }
498
+ case SettingComponentEnum.INPUT_BYTES:
499
+ isTextInputRef.current = true; // reaproveita o debounce de 500ms existente
500
+ return (
501
+ <ByteSizeInput
502
+ value={
503
+ typeof parsedValue === 'number'
504
+ ? parsedValue
505
+ : parseFloat(parsedValue) || 0
506
+ }
507
+ onChange={(bytes) => handleChangeValue(formatValue(bytes))}
508
+ persistKey={setting.slug}
509
+ className={cn(
510
+ className,
511
+ 'w-full min-w-0 bg-background md:min-w-50'
512
+ )}
513
+ />
514
+ );
512
515
 
513
- handleChangeValue(formatValue(fileUrl));
514
- showToastHandler('success', t('fileUploaded'));
515
- } catch (error) {
516
- showToastHandler('error', t('fileUploadFailed'));
517
- } finally {
518
- setIsUploading(false);
519
- if (fileInputRef.current) {
520
- fileInputRef.current.value = '';
521
- }
522
- }
523
- };
524
-
525
- return (
526
- <div className="flex w-full min-w-0 flex-col gap-2 md:min-w-50">
527
- {parsedValue && (
528
- <div>
529
- <img
530
- src={parsedValue || ''}
531
- alt={t('uploadedFileAlt')}
532
- className="max-w-full md:w-50"
533
- />
534
- </div>
535
- )}
536
- <div className="flex w-full min-w-0 items-center gap-2">
537
- <input
538
- ref={fileInputRef}
539
- type="file"
540
- onChange={handleFileUpload}
541
- className="hidden"
542
- id={`file-${setting.id}`}
516
+ case SettingComponentEnum.INPUT_SECRET:
517
+ isTextInputRef.current = true;
518
+ return (
519
+ <div className="relative w-full min-w-0 md:min-w-50">
520
+ <Input
521
+ type={showSecretValue ? 'text' : 'password'}
522
+ value={parsedValue || ''}
523
+ onChange={(e) => handleChangeValue(formatValue(e.target.value))}
524
+ className={cn(
525
+ className,
526
+ 'w-full min-w-0 bg-background pr-10 md:min-w-50'
527
+ )}
543
528
  />
544
529
  <Button
545
530
  type="button"
546
- variant="outline"
531
+ variant="ghost"
547
532
  size="icon"
548
- onClick={() => fileInputRef.current?.click()}
549
- disabled={isUploading}
550
- className="shrink-0"
551
- tooltip={t('uploadFile')}
533
+ className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
534
+ onClick={() => setShowSecretValue((prev) => !prev)}
535
+ tooltip={showSecretValue ? t('hidePassword') : t('showPassword')}
552
536
  >
553
- <Upload className="h-4 w-4" />
537
+ {showSecretValue ? (
538
+ <EyeOff className="h-4 w-4" />
539
+ ) : (
540
+ <Eye className="h-4 w-4" />
541
+ )}
554
542
  </Button>
555
- <Input
556
- type="text"
557
- value={parsedValue || ''}
558
- onChange={(e) => handleChangeValue(formatValue(e.target.value))}
559
- className="min-w-0 flex-1 bg-background"
560
- placeholder={t('fileUrlPlaceholder')}
561
- disabled={isUploading}
562
- />
563
543
  </div>
564
- </div>
565
- );
566
-
567
- case SettingComponentEnum.INPUT_TAGS: {
568
- const tagsValue: string[] = Array.isArray(parsedValue) ? parsedValue : [];
569
- return (
570
- <TagsInput
571
- value={tagsValue}
572
- onChange={(val) => handleChangeValue(formatValue(val), true)}
573
- className={cn(className, 'w-full min-w-0 bg-background md:min-w-50')}
574
- />
575
- );
576
- }
544
+ );
545
+
546
+ case SettingComponentEnum.INPUT_FILE:
547
+ isTextInputRef.current = true;
548
+
549
+ const handleFileUpload = async (
550
+ e: React.ChangeEvent<HTMLInputElement>
551
+ ) => {
552
+ const file = e.target.files?.[0];
553
+ if (!file) return;
554
+
555
+ setIsUploading(true);
556
+ try {
557
+ const formData = new FormData();
558
+ formData.append('file', file);
559
+
560
+ const response = await request<{ id?: number | string }>({
561
+ url: '/file',
562
+ method: 'POST',
563
+ data: formData,
564
+ headers: {
565
+ 'Content-Type': 'multipart/form-data',
566
+ },
567
+ });
568
+
569
+ const fileId = Number(response.data?.id);
570
+
571
+ if (!fileId) {
572
+ throw new Error(t('fileIdNotReturned'));
573
+ }
574
+
575
+ // `/file/image/:id` é público, imutável (Cache-Control + ETag) e não
576
+ // expira. A URL de `PUT /file/open/:id`, gravada aqui antes, carrega
577
+ // um JWT de 3600s: a setting virava link morto uma hora depois do
578
+ // upload. Absoluta de propósito — o mesmo valor é lido pelo <img> da
579
+ // sidebar, pelo favicon dinâmico, pelo metadata do layout e pelo
580
+ // e-mail transacional, que roda fora do browser.
581
+ const fileUrl = buildAbsoluteImageUrl(fileId);
577
582
 
578
- case SettingComponentEnum.SWITCH:
579
- return (
580
- <Switch
581
- checked={parsedValue || false}
582
- onCheckedChange={(checked) =>
583
- handleChangeValue(formatValue(checked), true)
583
+ if (!fileUrl) {
584
+ throw new Error(t('apiBaseUrlNotConfigured'));
585
+ }
586
+
587
+ handleChangeValue(formatValue(fileUrl));
588
+ showToastHandler('success', t('fileUploaded'));
589
+ } catch (error) {
590
+ showToastHandler('error', t('fileUploadFailed'));
591
+ } finally {
592
+ setIsUploading(false);
593
+ if (fileInputRef.current) {
594
+ fileInputRef.current.value = '';
595
+ }
584
596
  }
585
- className={cn('w-8! min-w-8 max-w-8', className)}
586
- />
587
- );
597
+ };
588
598
 
589
- case SettingComponentEnum.CHECKBOX:
590
- if (setting.type === SettingTypeEnum.ARRAY) {
591
- const options = setting.setting_list || [];
592
- const selectedValues: string[] = Array.isArray(parsedValue)
599
+ return (
600
+ <div className="flex w-full min-w-0 flex-col gap-2 md:min-w-50">
601
+ {parsedValue && (
602
+ <div>
603
+ <img
604
+ src={parsedValue || ''}
605
+ alt={t('uploadedFileAlt')}
606
+ className="max-w-full md:w-50"
607
+ />
608
+ </div>
609
+ )}
610
+ <div className="flex w-full min-w-0 items-center gap-2">
611
+ <input
612
+ ref={fileInputRef}
613
+ type="file"
614
+ onChange={handleFileUpload}
615
+ className="hidden"
616
+ id={`file-${setting.id}`}
617
+ />
618
+ <Button
619
+ type="button"
620
+ variant="outline"
621
+ size="icon"
622
+ onClick={() => fileInputRef.current?.click()}
623
+ disabled={isUploading}
624
+ className="shrink-0"
625
+ tooltip={t('uploadFile')}
626
+ >
627
+ <Upload className="h-4 w-4" />
628
+ </Button>
629
+ <Input
630
+ type="text"
631
+ value={parsedValue || ''}
632
+ onChange={(e) => handleChangeValue(formatValue(e.target.value))}
633
+ className="min-w-0 flex-1 bg-background"
634
+ placeholder={t('fileUrlPlaceholder')}
635
+ disabled={isUploading}
636
+ />
637
+ </div>
638
+ </div>
639
+ );
640
+
641
+ case SettingComponentEnum.INPUT_TAGS: {
642
+ const tagsValue: string[] = Array.isArray(parsedValue)
593
643
  ? parsedValue
594
644
  : [];
595
-
596
645
  return (
597
- <div
646
+ <TagsInput
647
+ value={tagsValue}
648
+ onChange={(val) => handleChangeValue(formatValue(val), true)}
598
649
  className={cn(
599
- 'flex w-full min-w-0 flex-col space-y-2 md:min-w-50',
600
- className
650
+ className,
651
+ 'w-full min-w-0 bg-background md:min-w-50'
601
652
  )}
602
- >
603
- {options.map((option) => {
604
- return (
605
- <div key={option.id} className="flex items-center space-x-2">
606
- <Checkbox
607
- className="bg-background"
608
- checked={selectedValues.includes(option.value)}
609
- onCheckedChange={(checked) => {
610
- let newValues = [...selectedValues];
611
- if (checked) {
612
- newValues.push(option.value);
613
- } else {
614
- newValues = newValues.filter(
615
- (val) => val !== option.value
616
- );
617
- }
618
- handleChangeValue(formatValue(newValues), true);
619
- }}
620
- />
621
- <Label>{option.value}</Label>
622
- </div>
623
- );
624
- })}
625
- </div>
653
+ />
626
654
  );
627
655
  }
628
- return (
629
- <Checkbox
630
- checked={parsedValue || false}
631
- onCheckedChange={(checked) =>
632
- handleChangeValue(formatValue(checked), true)
633
- }
634
- className={cn(className)}
635
- />
636
- );
637
-
638
- case SettingComponentEnum.COMBOBOX:
639
- const options = setting.setting_list || [];
640
- return (
641
- <Select
642
- value={parsedValue?.toString() || ''}
643
- onValueChange={(val) => handleChangeValue(formatValue(val), true)}
644
- >
645
- <SelectTrigger
646
- className={cn(
647
- 'w-full min-w-0 bg-background md:min-w-50',
648
- className
649
- )}
650
- >
651
- <SelectValue placeholder={t('selectOption')} />
652
- </SelectTrigger>
653
- <SelectContent>
654
- {options.map((option) => (
655
- <SelectItem key={option.id} value={option.value}>
656
- {optionLabel(option.value)}
657
- </SelectItem>
658
- ))}
659
- </SelectContent>
660
- </Select>
661
- );
662
-
663
- case SettingComponentEnum.RADIO:
664
- const radioOptions = setting.setting_list || [];
665
- return (
666
- <RadioGroup
667
- value={parsedValue?.toString() || ''}
668
- onValueChange={(val) => handleChangeValue(formatValue(val), true)}
669
- className={cn(className, 'w-full min-w-0 md:min-w-50')}
670
- >
671
- {radioOptions.map((option) => (
672
- <div key={option.id} className="flex items-center space-x-2">
673
- <RadioGroupItem
674
- value={option.value}
675
- id={`radio-${option.id}`}
676
- className="bg-background"
677
- />
678
- <Label htmlFor={`radio-${option.id}`}>
679
- {optionLabel(option.value)}
680
- </Label>
681
- </div>
682
- ))}
683
- </RadioGroup>
684
- );
685
-
686
- case SettingComponentEnum.COLOR_PICKER:
687
- isTextInputRef.current = true;
688
- return (
689
- <div className="flex w-full min-w-0 items-center gap-2 md:max-w-50">
690
- <Input
691
- type="color"
692
- value={parsedValue || '#000000'}
693
- onChange={(e) => handleChangeValue(formatValue(e.target.value))}
694
- className={cn(
695
- 'h-10 w-10 border-none p-0 cursor-pointer',
696
- className
697
- )}
656
+
657
+ case SettingComponentEnum.SWITCH:
658
+ return (
659
+ <Switch
660
+ checked={parsedValue || false}
661
+ onCheckedChange={(checked) =>
662
+ handleChangeValue(formatValue(checked), true)
663
+ }
664
+ className={cn('w-8! min-w-8 max-w-8', className)}
698
665
  />
699
- <Input
700
- type="text"
701
- value={parsedValue || '#000000'}
702
- onChange={(e) => handleChangeValue(formatValue(e.target.value))}
703
- className="flex-1 bg-background"
704
- placeholder="#000000"
705
- maxLength={7}
706
- minLength={7}
666
+ );
667
+
668
+ case SettingComponentEnum.CHECKBOX:
669
+ if (setting.type === SettingTypeEnum.ARRAY) {
670
+ const options = setting.setting_list || [];
671
+ const selectedValues: string[] = Array.isArray(parsedValue)
672
+ ? parsedValue
673
+ : [];
674
+
675
+ return (
676
+ <div
677
+ className={cn(
678
+ 'flex w-full min-w-0 flex-col space-y-2 md:min-w-50',
679
+ className
680
+ )}
681
+ >
682
+ {options.map((option) => {
683
+ return (
684
+ <div key={option.id} className="flex items-center space-x-2">
685
+ <Checkbox
686
+ className="bg-background"
687
+ checked={selectedValues.includes(option.value)}
688
+ onCheckedChange={(checked) => {
689
+ let newValues = [...selectedValues];
690
+ if (checked) {
691
+ newValues.push(option.value);
692
+ } else {
693
+ newValues = newValues.filter(
694
+ (val) => val !== option.value
695
+ );
696
+ }
697
+ handleChangeValue(formatValue(newValues), true);
698
+ }}
699
+ />
700
+ <Label>{option.value}</Label>
701
+ </div>
702
+ );
703
+ })}
704
+ </div>
705
+ );
706
+ }
707
+ return (
708
+ <Checkbox
709
+ checked={parsedValue || false}
710
+ onCheckedChange={(checked) =>
711
+ handleChangeValue(formatValue(checked), true)
712
+ }
713
+ className={cn(className)}
707
714
  />
708
- </div>
709
- );
710
-
711
- // Read-only navigation shortcut: takes the user from a module's settings tab
712
- // to a related screen. Deliberately does NOT set isTextInputRef nor call
713
- // handleChangeValue — rendering it must never write anything.
714
- case SettingComponentEnum.LINK: {
715
- const href = String(parsedValue || '');
716
- // Internal paths only. A setting value must not become an open redirect.
717
- const safeHref =
718
- href.startsWith('/') && !href.startsWith('//') ? href : '#';
719
-
720
- return (
721
- <Button asChild variant="outline" size="sm" disabled={safeHref === '#'}>
722
- <Link href={safeHref}>
723
- <ExternalLink className="h-4 w-4" />
724
- {t('openLink')}
725
- </Link>
726
- </Button>
727
- );
728
- }
715
+ );
729
716
 
730
- case SettingComponentEnum.ENTITY_PICKER: {
731
- const profileConfig = getIntegrationProfileSettingConfig(setting);
717
+ case SettingComponentEnum.COMBOBOX:
718
+ const options = setting.setting_list || [];
719
+ return (
720
+ <Select
721
+ value={parsedValue?.toString() || ''}
722
+ onValueChange={(val) => handleChangeValue(formatValue(val), true)}
723
+ >
724
+ <SelectTrigger
725
+ className={cn(
726
+ 'w-full min-w-0 bg-background md:min-w-50',
727
+ className
728
+ )}
729
+ >
730
+ <SelectValue placeholder={t('selectOption')} />
731
+ </SelectTrigger>
732
+ <SelectContent>
733
+ {options.map((option) => (
734
+ <SelectItem key={option.id} value={option.value}>
735
+ {optionLabel(option.value)}
736
+ </SelectItem>
737
+ ))}
738
+ </SelectContent>
739
+ </Select>
740
+ );
741
+
742
+ case SettingComponentEnum.RADIO:
743
+ const radioOptions = setting.setting_list || [];
744
+ return (
745
+ <RadioGroup
746
+ value={parsedValue?.toString() || ''}
747
+ onValueChange={(val) => handleChangeValue(formatValue(val), true)}
748
+ className={cn(className, 'w-full min-w-0 md:min-w-50')}
749
+ >
750
+ {radioOptions.map((option) => (
751
+ <div key={option.id} className="flex items-center space-x-2">
752
+ <RadioGroupItem
753
+ value={option.value}
754
+ id={`radio-${option.id}`}
755
+ className="bg-background"
756
+ />
757
+ <Label htmlFor={`radio-${option.id}`}>
758
+ {optionLabel(option.value)}
759
+ </Label>
760
+ </div>
761
+ ))}
762
+ </RadioGroup>
763
+ );
732
764
 
733
- if (!profileConfig) {
765
+ case SettingComponentEnum.COLOR_PICKER:
734
766
  isTextInputRef.current = true;
735
767
  return (
736
- <Input
737
- type="text"
738
- value={parsedValue || ''}
739
- onChange={(e) => handleChangeValue(formatValue(e.target.value))}
740
- className={cn(className, 'bg-background')}
741
- />
768
+ <div className="flex w-full min-w-0 items-center gap-2 md:max-w-50">
769
+ <Input
770
+ type="color"
771
+ value={parsedValue || '#000000'}
772
+ onChange={(e) => handleChangeValue(formatValue(e.target.value))}
773
+ className={cn(
774
+ 'h-10 w-10 border-none p-0 cursor-pointer',
775
+ className
776
+ )}
777
+ />
778
+ <Input
779
+ type="text"
780
+ value={parsedValue || '#000000'}
781
+ onChange={(e) => handleChangeValue(formatValue(e.target.value))}
782
+ className="flex-1 bg-background"
783
+ placeholder="#000000"
784
+ maxLength={7}
785
+ minLength={7}
786
+ />
787
+ </div>
788
+ );
789
+
790
+ // Read-only navigation shortcut: takes the user from a module's settings tab
791
+ // to a related screen. Deliberately does NOT set isTextInputRef nor call
792
+ // handleChangeValue — rendering it must never write anything.
793
+ case SettingComponentEnum.LINK: {
794
+ const href = String(parsedValue || '');
795
+ // Internal paths only. A setting value must not become an open redirect.
796
+ const safeHref =
797
+ href.startsWith('/') && !href.startsWith('//') ? href : '#';
798
+
799
+ return (
800
+ <Button
801
+ asChild
802
+ variant="outline"
803
+ size="sm"
804
+ disabled={safeHref === '#'}
805
+ >
806
+ <Link href={safeHref}>
807
+ <ExternalLink className="h-4 w-4" />
808
+ {t('openLink')}
809
+ </Link>
810
+ </Button>
742
811
  );
743
812
  }
744
813
 
745
- const { typeSlug, providerSlug, excludeProviders } = profileConfig;
746
- // Um tipo sem tradução própria (ou sem filtro nenhum) cai no genérico.
747
- const profileTypeKey =
748
- typeSlug && t.has(`profileTypes.${typeSlug}.placeholder`)
749
- ? `profileTypes.${typeSlug}`
750
- : GENERIC_PROFILE_TYPE_KEY;
751
- // Provider com nome próprio (Kubernetes) manda no rótulo dos botões;
752
- // sem ele, o substantivo é o do tipo.
753
- const profileName =
754
- providerSlug && t.has(`profileProviders.${providerSlug}`)
755
- ? t(`profileProviders.${providerSlug}`)
756
- : t(`${profileTypeKey}.name`);
757
-
758
- // O picker exibe o nome do perfil, mas a setting guarda o slug.
759
- const applyProfile = (
760
- profile: {
761
- id?: number | null;
762
- slug?: string | null;
763
- name?: string | null;
764
- } | null
765
- ) => {
766
- setStorageProfileSelectedLabel(profile?.name ?? '');
767
- setResolvedProfileId(profile?.id ?? null);
768
- handleChangeValue(profile?.slug ? String(profile.slug) : '', true);
769
- };
770
-
771
- return (
772
- <>
773
- <div className="flex w-full min-w-0 items-center gap-2 md:max-w-72">
774
- <EntityPicker<IntegrationProfileOption & Record<string, unknown>>
775
- placeholder={t(`${profileTypeKey}.placeholder`)}
776
- emptyStateDescription={t(`${profileTypeKey}.emptyState`)}
777
- value={parsedValue || null}
778
- onChange={(slug, option) =>
779
- applyProfile(
780
- option
781
- ? {
782
- id: Number((option as { id?: number }).id) || null,
783
- slug: slug != null ? String(slug) : '',
784
- name: option.name ?? '',
785
- }
786
- : null
787
- )
788
- }
789
- valueType="string"
790
- initialSelectedLabel={storageProfileSelectedLabel}
791
- showCreateButton={false}
792
- clearable={false}
793
- loadOptions={async ({ page, pageSize, search }) => {
794
- const qs = new URLSearchParams({
795
- page: String(page),
796
- pageSize: String(pageSize),
797
- });
798
- if (typeSlug) qs.set('typeSlug', typeSlug);
799
- if (providerSlug) qs.set('providerSlug', providerSlug);
800
- if (search) qs.set('search', search);
801
-
802
- const response = await request<
803
- ApiPaginated<
804
- IntegrationProfileOption & Record<string, unknown>
805
- >
806
- >({
807
- url: `/integration-profile?${qs}`,
808
- method: 'GET',
809
- });
810
- const paged = response.data;
811
-
812
- return {
813
- // O endpoint filtra por tipo e por provider; o que sobra
814
- // (Google Play como gateway do checkout web, por exemplo) sai
815
- // aqui para não oferecer uma escolha que o backend recusaria.
816
- items: (paged?.data ?? []).filter(
817
- (profile) =>
818
- !excludeProviders?.includes(
819
- String(profile.integration_provider?.slug ?? '')
820
- )
821
- ),
822
- hasMore: (paged?.page ?? 0) < (paged?.lastPage ?? 0),
823
- };
824
- }}
825
- getOptionValue={(opt) => opt.slug}
826
- getOptionLabel={(opt) => String(opt.name ?? '')}
827
- getOptionDescription={(opt) => {
828
- const optionProviderSlug = String(
829
- opt.integration_provider?.slug ?? ''
830
- );
831
- const providerName = getLocalizedIntegrationName(
832
- opt.integration_provider?.integration_provider_locale,
833
- currentLocaleCode
834
- );
835
- return [providerName || optionProviderSlug, opt.slug]
836
- .filter(Boolean)
837
- .join(' • ');
838
- }}
839
- className={cn('flex-1 min-w-0', className)}
814
+ case SettingComponentEnum.ENTITY_PICKER: {
815
+ const profileConfig = getIntegrationProfileSettingConfig(setting);
816
+
817
+ if (!profileConfig) {
818
+ isTextInputRef.current = true;
819
+ return (
820
+ <Input
821
+ type="text"
822
+ value={parsedValue || ''}
823
+ onChange={(e) => handleChangeValue(formatValue(e.target.value))}
824
+ className={cn(className, 'bg-background')}
840
825
  />
826
+ );
827
+ }
841
828
 
842
- <Button
843
- type="button"
844
- variant="outline"
845
- size="icon"
846
- className="h-10 w-10 shrink-0"
847
- onClick={() => {
848
- setStorageProfileEditingId(resolvedProfileId);
849
- setIsStorageProfileSheetOpen(true);
850
- }}
851
- disabled={!resolvedProfileId}
852
- tooltip={t('editProfileAria', { profile: profileName })}
853
- >
854
- <Pencil className="h-4 w-4" />
855
- </Button>
829
+ const { typeSlug, providerSlug, excludeProviders } = profileConfig;
830
+ // Um tipo sem tradução própria (ou sem filtro nenhum) cai no genérico.
831
+ const profileTypeKey =
832
+ typeSlug && t.has(`profileTypes.${typeSlug}.placeholder`)
833
+ ? `profileTypes.${typeSlug}`
834
+ : GENERIC_PROFILE_TYPE_KEY;
835
+ // Provider com nome próprio (Kubernetes) manda no rótulo dos botões;
836
+ // sem ele, o substantivo é o do tipo.
837
+ const profileName =
838
+ providerSlug && t.has(`profileProviders.${providerSlug}`)
839
+ ? t(`profileProviders.${providerSlug}`)
840
+ : t(`${profileTypeKey}.name`);
841
+
842
+ // O picker exibe o nome do perfil, mas a setting guarda o slug.
843
+ const applyProfile = (
844
+ profile: {
845
+ id?: number | null;
846
+ slug?: string | null;
847
+ name?: string | null;
848
+ } | null
849
+ ) => {
850
+ setStorageProfileSelectedLabel(profile?.name ?? '');
851
+ setResolvedProfileId(profile?.id ?? null);
852
+ handleChangeValue(profile?.slug ? String(profile.slug) : '', true);
853
+ };
856
854
 
857
- <Button
858
- type="button"
859
- variant="outline"
860
- size="icon"
861
- className="h-10 w-10 shrink-0"
862
- onClick={() => {
863
- setStorageProfileEditingId(null);
864
- setIsStorageProfileSheetOpen(true);
855
+ return (
856
+ <>
857
+ <div className="flex w-full min-w-0 items-center gap-2 md:max-w-72">
858
+ <EntityPicker<IntegrationProfileOption & Record<string, unknown>>
859
+ placeholder={t(`${profileTypeKey}.placeholder`)}
860
+ emptyStateDescription={t(`${profileTypeKey}.emptyState`)}
861
+ value={parsedValue || null}
862
+ onChange={(slug, option) =>
863
+ applyProfile(
864
+ option
865
+ ? {
866
+ id: Number((option as { id?: number }).id) || null,
867
+ slug: slug != null ? String(slug) : '',
868
+ name: option.name ?? '',
869
+ }
870
+ : null
871
+ )
872
+ }
873
+ valueType="string"
874
+ initialSelectedLabel={storageProfileSelectedLabel}
875
+ showCreateButton={false}
876
+ clearable={false}
877
+ loadOptions={async ({ page, pageSize, search }) => {
878
+ const qs = new URLSearchParams({
879
+ page: String(page),
880
+ pageSize: String(pageSize),
881
+ });
882
+ if (typeSlug) qs.set('typeSlug', typeSlug);
883
+ if (providerSlug) qs.set('providerSlug', providerSlug);
884
+ if (search) qs.set('search', search);
885
+
886
+ const response = await request<
887
+ ApiPaginated<
888
+ IntegrationProfileOption & Record<string, unknown>
889
+ >
890
+ >({
891
+ url: `/integration-profile?${qs}`,
892
+ method: 'GET',
893
+ });
894
+ const paged = response.data;
895
+
896
+ return {
897
+ // O endpoint filtra por tipo e por provider; o que sobra
898
+ // (Google Play como gateway do checkout web, por exemplo) sai
899
+ // aqui para não oferecer uma escolha que o backend recusaria.
900
+ items: (paged?.data ?? []).filter(
901
+ (profile) =>
902
+ !excludeProviders?.includes(
903
+ String(profile.integration_provider?.slug ?? '')
904
+ )
905
+ ),
906
+ hasMore: (paged?.page ?? 0) < (paged?.lastPage ?? 0),
907
+ };
908
+ }}
909
+ getOptionValue={(opt) => opt.slug}
910
+ getOptionLabel={(opt) => String(opt.name ?? '')}
911
+ getOptionDescription={(opt) => {
912
+ const optionProviderSlug = String(
913
+ opt.integration_provider?.slug ?? ''
914
+ );
915
+ const providerName = getLocalizedIntegrationName(
916
+ opt.integration_provider?.integration_provider_locale,
917
+ currentLocaleCode
918
+ );
919
+ return [providerName || optionProviderSlug, opt.slug]
920
+ .filter(Boolean)
921
+ .join(' • ');
922
+ }}
923
+ className={cn('flex-1 min-w-0', className)}
924
+ />
925
+
926
+ <Button
927
+ type="button"
928
+ variant="outline"
929
+ size="icon"
930
+ className="h-10 w-10 shrink-0"
931
+ onClick={() => {
932
+ setStorageProfileEditingId(resolvedProfileId);
933
+ setIsStorageProfileSheetOpen(true);
934
+ }}
935
+ disabled={!resolvedProfileId}
936
+ tooltip={t('editProfileAria', { profile: profileName })}
937
+ >
938
+ <Pencil className="h-4 w-4" />
939
+ </Button>
940
+
941
+ <Button
942
+ type="button"
943
+ variant="outline"
944
+ size="icon"
945
+ className="h-10 w-10 shrink-0"
946
+ onClick={() => {
947
+ setStorageProfileEditingId(null);
948
+ setIsStorageProfileSheetOpen(true);
949
+ }}
950
+ tooltip={t('createProfileAria', { profile: profileName })}
951
+ >
952
+ <Plus className="h-4 w-4" />
953
+ </Button>
954
+ </div>
955
+
956
+ <IntegrationProfileSheet
957
+ open={isStorageProfileSheetOpen}
958
+ onOpenChange={(nextOpen) => {
959
+ setIsStorageProfileSheetOpen(nextOpen);
960
+ if (!nextOpen) setStorageProfileEditingId(null);
865
961
  }}
866
- tooltip={t('createProfileAria', { profile: profileName })}
867
- >
868
- <Plus className="h-4 w-4" />
869
- </Button>
870
- </div>
962
+ profileId={storageProfileEditingId}
963
+ initialTypeSlug={typeSlug}
964
+ lockedTypeSlug={typeSlug}
965
+ lockedProviderSlug={providerSlug}
966
+ excludeProviderSlugs={excludeProviders}
967
+ onSaved={(profile: IntegrationProfileSheetSavedProfile) =>
968
+ applyProfile(profile)
969
+ }
970
+ />
971
+ </>
972
+ );
973
+ }
871
974
 
872
- <IntegrationProfileSheet
873
- open={isStorageProfileSheetOpen}
874
- onOpenChange={(nextOpen) => {
875
- setIsStorageProfileSheetOpen(nextOpen);
876
- if (!nextOpen) setStorageProfileEditingId(null);
877
- }}
878
- profileId={storageProfileEditingId}
879
- initialTypeSlug={typeSlug}
880
- lockedTypeSlug={typeSlug}
881
- lockedProviderSlug={providerSlug}
882
- excludeProviderSlugs={excludeProviders}
883
- onSaved={(profile: IntegrationProfileSheetSavedProfile) =>
884
- applyProfile(profile)
885
- }
975
+ default:
976
+ return (
977
+ <Input
978
+ type="text"
979
+ value={parsedValue || ''}
980
+ onChange={(e) => handleChangeValue(formatValue(e.target.value))}
981
+ className={cn(className, 'bg-background')}
886
982
  />
887
- </>
888
- );
983
+ );
889
984
  }
890
-
891
- default:
892
- return (
893
- <Input
894
- type="text"
895
- value={parsedValue || ''}
896
- onChange={(e) => handleChangeValue(formatValue(e.target.value))}
897
- className={cn(className, 'bg-background')}
898
- />
899
- );
900
985
  }
901
986
  };