@omnizap-system/omnizap 2.5.12

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 (425) hide show
  1. package/.clusterfuzzlite/Dockerfile +10 -0
  2. package/.env.example +907 -0
  3. package/.github/codeql/codeql-config.yml +10 -0
  4. package/.github/dependabot.yml +35 -0
  5. package/.github/workflows/ci.yml +73 -0
  6. package/.github/workflows/codeql.yml +106 -0
  7. package/.github/workflows/db-migration-check.yml +98 -0
  8. package/.github/workflows/dependency-review.yml +22 -0
  9. package/.github/workflows/deploy.yml +95 -0
  10. package/.github/workflows/release.yml +106 -0
  11. package/.github/workflows/security-attest-provenance.yml +51 -0
  12. package/.github/workflows/security-gitleaks.yml +34 -0
  13. package/.github/workflows/security-runner-hardening.yml +31 -0
  14. package/.github/workflows/security-scorecard.yml +44 -0
  15. package/.github/workflows/security-zap-baseline.yml +44 -0
  16. package/.github/workflows/security-zap-full-scan.yml +43 -0
  17. package/.github/workflows/security-zizmor.yml +36 -0
  18. package/.github/workflows/wiki-sync.yml +44 -0
  19. package/.gitleaks.toml +15 -0
  20. package/.prettierrc +34 -0
  21. package/CODE_OF_CONDUCT.md +114 -0
  22. package/LICENSE +56 -0
  23. package/README.md +110 -0
  24. package/SECURITY.md +110 -0
  25. package/app/config/index.js +4 -0
  26. package/app/configParts/adminIdentity.js +92 -0
  27. package/app/configParts/baileysConfig.js +1818 -0
  28. package/app/configParts/groupUtils.js +692 -0
  29. package/app/configParts/loggerConfig.js +394 -0
  30. package/app/configParts/messagePersistenceService.js +305 -0
  31. package/app/connection/baileysCompatibility.test.js +40 -0
  32. package/app/connection/baileysDbAuthState.js +344 -0
  33. package/app/connection/socketController.js +2243 -0
  34. package/app/controllers/messageController.js +7 -0
  35. package/app/controllers/messagePipeline/commandMiddleware.js +146 -0
  36. package/app/controllers/messagePipeline/conversationMiddleware.js +183 -0
  37. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +522 -0
  38. package/app/controllers/messagePipeline/postProcessingMiddleware.js +41 -0
  39. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +166 -0
  40. package/app/controllers/messageProcessingPipeline.js +699 -0
  41. package/app/modules/adminModule/AGENT.md +4056 -0
  42. package/app/modules/adminModule/adminAiHelpService.js +56 -0
  43. package/app/modules/adminModule/adminConfigRuntime.js +177 -0
  44. package/app/modules/adminModule/commandConfig.json +7122 -0
  45. package/app/modules/adminModule/groupCommandHandlers.js +1823 -0
  46. package/app/modules/adminModule/groupCommandHandlers.test.js +350 -0
  47. package/app/modules/adminModule/groupEventHandlers.js +399 -0
  48. package/app/modules/aiModule/AGENT.md +547 -0
  49. package/app/modules/aiModule/aiAiHelpService.js +14 -0
  50. package/app/modules/aiModule/aiConfigRuntime.js +135 -0
  51. package/app/modules/aiModule/catCommand.js +967 -0
  52. package/app/modules/aiModule/commandConfig.json +981 -0
  53. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
  54. package/app/modules/gameModule/AGENT.md +196 -0
  55. package/app/modules/gameModule/commandConfig.json +366 -0
  56. package/app/modules/gameModule/diceCommand.js +42 -0
  57. package/app/modules/gameModule/gameAiHelpService.js +14 -0
  58. package/app/modules/gameModule/gameConfigRuntime.js +68 -0
  59. package/app/modules/menuModule/AGENT.md +205 -0
  60. package/app/modules/menuModule/commandConfig.json +366 -0
  61. package/app/modules/menuModule/common.js +316 -0
  62. package/app/modules/menuModule/menuAiHelpService.js +14 -0
  63. package/app/modules/menuModule/menuConfigRuntime.js +68 -0
  64. package/app/modules/menuModule/menus.js +66 -0
  65. package/app/modules/playModule/AGENT.md +321 -0
  66. package/app/modules/playModule/commandConfig.json +584 -0
  67. package/app/modules/playModule/playAiHelpService.js +14 -0
  68. package/app/modules/playModule/playCommand.js +1417 -0
  69. package/app/modules/playModule/playConfigRuntime.js +68 -0
  70. package/app/modules/quoteModule/AGENT.md +199 -0
  71. package/app/modules/quoteModule/commandConfig.json +366 -0
  72. package/app/modules/quoteModule/quoteAiHelpService.js +14 -0
  73. package/app/modules/quoteModule/quoteCommand.js +842 -0
  74. package/app/modules/quoteModule/quoteConfigRuntime.js +68 -0
  75. package/app/modules/rpgPokemonModule/AGENT.md +229 -0
  76. package/app/modules/rpgPokemonModule/commandConfig.json +386 -0
  77. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +795 -0
  78. package/app/modules/rpgPokemonModule/rpgBattleService.js +2110 -0
  79. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +770 -0
  80. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  81. package/app/modules/rpgPokemonModule/rpgPokemonAiHelpService.js +14 -0
  82. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +174 -0
  83. package/app/modules/rpgPokemonModule/rpgPokemonConfigRuntime.js +68 -0
  84. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  85. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  86. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  87. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  88. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1847 -0
  89. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6839 -0
  90. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  91. package/app/modules/statsModule/AGENT.md +320 -0
  92. package/app/modules/statsModule/commandConfig.json +540 -0
  93. package/app/modules/statsModule/globalRankingCommand.js +64 -0
  94. package/app/modules/statsModule/rankingCommand.js +41 -0
  95. package/app/modules/statsModule/rankingCommon.js +1305 -0
  96. package/app/modules/statsModule/statsAiHelpService.js +14 -0
  97. package/app/modules/statsModule/statsConfigRuntime.js +68 -0
  98. package/app/modules/stickerModule/AGENT.md +692 -0
  99. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  100. package/app/modules/stickerModule/commandConfig.json +1216 -0
  101. package/app/modules/stickerModule/convertToWebp.js +367 -0
  102. package/app/modules/stickerModule/stickerAiHelpService.js +14 -0
  103. package/app/modules/stickerModule/stickerCommand.js +446 -0
  104. package/app/modules/stickerModule/stickerConfigRuntime.js +68 -0
  105. package/app/modules/stickerModule/stickerConvertCommand.js +159 -0
  106. package/app/modules/stickerModule/stickerTextCommand.js +653 -0
  107. package/app/modules/stickerPackModule/AGENT.md +215 -0
  108. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  109. package/app/modules/stickerPackModule/autoPackCollectorService.js +357 -0
  110. package/app/modules/stickerPackModule/commandConfig.json +387 -0
  111. package/app/modules/stickerPackModule/domainEventOutboxRepository.js +227 -0
  112. package/app/modules/stickerPackModule/domainEvents.js +52 -0
  113. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +429 -0
  114. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +75 -0
  115. package/app/modules/stickerPackModule/semanticThemeClusterService.js +544 -0
  116. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +400 -0
  117. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  118. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +175 -0
  119. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +3702 -0
  120. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +559 -0
  121. package/app/modules/stickerPackModule/stickerClassificationService.js +557 -0
  122. package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +249 -0
  123. package/app/modules/stickerPackModule/stickerDomainEventBus.js +65 -0
  124. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +208 -0
  125. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +99 -0
  126. package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
  127. package/app/modules/stickerPackModule/stickerPackAiHelpService.js +14 -0
  128. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1148 -0
  129. package/app/modules/stickerPackModule/stickerPackConfigRuntime.js +68 -0
  130. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +152 -0
  131. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  132. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +101 -0
  133. package/app/modules/stickerPackModule/stickerPackItemRepository.js +432 -0
  134. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +313 -0
  135. package/app/modules/stickerPackModule/stickerPackMessageService.js +268 -0
  136. package/app/modules/stickerPackModule/stickerPackRepository.js +450 -0
  137. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +179 -0
  138. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +271 -0
  139. package/app/modules/stickerPackModule/stickerPackService.js +733 -0
  140. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +32 -0
  141. package/app/modules/stickerPackModule/stickerPackUtils.js +107 -0
  142. package/app/modules/stickerPackModule/stickerStorageService.js +559 -0
  143. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +242 -0
  144. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +242 -0
  145. package/app/modules/systemMetricsModule/AGENT.md +193 -0
  146. package/app/modules/systemMetricsModule/commandConfig.json +344 -0
  147. package/app/modules/systemMetricsModule/pingCommand.js +399 -0
  148. package/app/modules/systemMetricsModule/systemMetricsAiHelpService.js +14 -0
  149. package/app/modules/systemMetricsModule/systemMetricsConfigRuntime.js +68 -0
  150. package/app/modules/tiktokModule/AGENT.md +196 -0
  151. package/app/modules/tiktokModule/commandConfig.json +366 -0
  152. package/app/modules/tiktokModule/tiktokAiHelpService.js +14 -0
  153. package/app/modules/tiktokModule/tiktokCommand.js +716 -0
  154. package/app/modules/tiktokModule/tiktokConfigRuntime.js +68 -0
  155. package/app/modules/userModule/AGENT.md +200 -0
  156. package/app/modules/userModule/commandConfig.json +386 -0
  157. package/app/modules/userModule/userAiHelpService.js +14 -0
  158. package/app/modules/userModule/userCommand.js +1155 -0
  159. package/app/modules/userModule/userConfigRuntime.js +68 -0
  160. package/app/modules/waifuPicsModule/AGENT.md +431 -0
  161. package/app/modules/waifuPicsModule/commandConfig.json +780 -0
  162. package/app/modules/waifuPicsModule/waifuPicsAiHelpService.js +14 -0
  163. package/app/modules/waifuPicsModule/waifuPicsCommand.js +586 -0
  164. package/app/modules/waifuPicsModule/waifuPicsConfigRuntime.js +68 -0
  165. package/app/observability/metrics.js +766 -0
  166. package/app/services/ai/aiHelpResponseCacheRepository.js +280 -0
  167. package/app/services/ai/aiLearningRepository.js +400 -0
  168. package/app/services/ai/commandConfigEnrichmentRepository.js +769 -0
  169. package/app/services/ai/commandConfigEnrichmentService.js +452 -0
  170. package/app/services/ai/commandConfigValidationService.js +443 -0
  171. package/app/services/ai/commandToolBuilderService.js +192 -0
  172. package/app/services/ai/conversationRouterService.js +516 -0
  173. package/app/services/ai/geminiService.js +115 -0
  174. package/app/services/ai/geminiService.test.js +87 -0
  175. package/app/services/ai/globalModuleAiHelpService.js +1412 -0
  176. package/app/services/ai/globalToolCallingService.js +203 -0
  177. package/app/services/ai/messageCommandExecutionService.js +391 -0
  178. package/app/services/ai/moduleAiHelpCoreService.js +1099 -0
  179. package/app/services/ai/moduleAiHelpWrapperFactory.js +65 -0
  180. package/app/services/ai/moduleCommandConfigRuntimeService.js +113 -0
  181. package/app/services/ai/moduleToolExecutorService.js +464 -0
  182. package/app/services/ai/moduleToolRegistryService.js +178 -0
  183. package/app/services/ai/toolCandidateSelectorService.js +781 -0
  184. package/app/services/auth/googleWebLinkService.js +80 -0
  185. package/app/services/auth/whatsappLoginLinkService.js +230 -0
  186. package/app/services/external/pokeApiService.js +398 -0
  187. package/app/services/group/groupMetadataService.js +311 -0
  188. package/app/services/infra/dbWriteQueue.js +874 -0
  189. package/app/services/infra/featureFlagService.js +131 -0
  190. package/app/services/infra/queueUtils.js +55 -0
  191. package/app/services/messaging/captchaService.js +491 -0
  192. package/app/services/messaging/messagePersistenceService.js +1 -0
  193. package/app/services/messaging/newsBroadcastService.js +347 -0
  194. package/app/services/sticker/stickerFocusService.js +347 -0
  195. package/app/services/sticker/stickerFocusService.test.js +43 -0
  196. package/app/store/aiPromptStore.js +38 -0
  197. package/app/store/conversationSessionStore.js +131 -0
  198. package/app/store/groupConfigStore.js +58 -0
  199. package/app/store/premiumUserStore.js +54 -0
  200. package/app/utils/antiLink/antiLinkModule.js +700 -0
  201. package/app/utils/http/getImageBufferModule.js +18 -0
  202. package/app/utils/json/jsonSanitizer.js +113 -0
  203. package/app/utils/json/jsonSanitizer.test.js +40 -0
  204. package/app/utils/systemMetrics/systemMetricsModule.js +88 -0
  205. package/app/workers/aiLearningWorker.js +605 -0
  206. package/app/workers/commandConfigEnrichmentWorker.js +242 -0
  207. package/database/index.js +2075 -0
  208. package/database/init.js +151 -0
  209. package/database/migrations/.gitkeep +0 -0
  210. package/database/migrations/20260307_d0_hardening_down.sql +64 -0
  211. package/database/migrations/20260307_d0_hardening_up.sql +79 -0
  212. package/database/migrations/20260307_d1_terms_acceptance_down.sql +11 -0
  213. package/database/migrations/20260307_d1_terms_acceptance_up.sql +37 -0
  214. package/database/migrations/20260307_d2_auth_hardening_down.sql +75 -0
  215. package/database/migrations/20260307_d2_auth_hardening_up.sql +100 -0
  216. package/database/migrations/20260314_d7_canonical_sender_down.sql +53 -0
  217. package/database/migrations/20260314_d7_canonical_sender_up.sql +114 -0
  218. package/database/migrations/20260406_d30_security_analytics_down.sql +95 -0
  219. package/database/migrations/20260406_d30_security_analytics_up.sql +292 -0
  220. package/database/migrations/20260407_d31_web_google_session_token_hardening_down.sql +2 -0
  221. package/database/migrations/20260407_d31_web_google_session_token_hardening_up.sql +17 -0
  222. package/database/migrations/20260408_d32_ai_help_response_cache_down.sql +1 -0
  223. package/database/migrations/20260408_d32_ai_help_response_cache_up.sql +22 -0
  224. package/database/migrations/20260409_d33_ai_learning_tables_down.sql +4 -0
  225. package/database/migrations/20260409_d33_ai_learning_tables_up.sql +52 -0
  226. package/database/migrations/20260410_d34_command_config_enrichment_down.sql +3 -0
  227. package/database/migrations/20260410_d34_command_config_enrichment_up.sql +48 -0
  228. package/database/schema.sql +1186 -0
  229. package/docker-compose.yml +104 -0
  230. package/docs/audits/stickerCatalogController-out-of-scope.md +103 -0
  231. package/docs/audits/stickerCatalogController-symbols.md +58 -0
  232. package/docs/compliance/acceptable-use-policy-2026-03-07.md +35 -0
  233. package/docs/compliance/dpa-b2b-standard-2026-03-07.md +80 -0
  234. package/docs/compliance/monthly-compliance-checklist-2026-03-07.md +88 -0
  235. package/docs/compliance/notice-and-takedown-policy-2026-03-07.md +34 -0
  236. package/docs/compliance/privacy-policy-2026-03-07.md +75 -0
  237. package/docs/compliance/subprocessors-inventory-2026-03-07.md +16 -0
  238. package/docs/database/production-db-evolution-runbook-2026q1.md +365 -0
  239. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +86 -0
  240. package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +77 -0
  241. package/docs/security/network-hardening-runbook-2026-03-07.md +137 -0
  242. package/docs/seo/omnizap-seo-playbook-br-2026-02-28.md +238 -0
  243. package/docs/seo/satellite-page-template.md +116 -0
  244. package/docs/seo/satellite-pages-phase1.json +364 -0
  245. package/docs/wiki/Home.md +120 -0
  246. package/docs/wiki/pair-extraordinaire-2026-03-08.md +3 -0
  247. package/docs/wiki/recent-changes-2026-03-08.md +47 -0
  248. package/ecosystem.prod.config.cjs +135 -0
  249. package/eslint.config.js +89 -0
  250. package/index.js +488 -0
  251. package/ml/clip_classifier/Dockerfile +18 -0
  252. package/ml/clip_classifier/README.md +118 -0
  253. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  254. package/ml/clip_classifier/classifier.py +654 -0
  255. package/ml/clip_classifier/embedding_store.py +481 -0
  256. package/ml/clip_classifier/env_loader.py +15 -0
  257. package/ml/clip_classifier/llm_label_expander.py +144 -0
  258. package/ml/clip_classifier/main.py +213 -0
  259. package/ml/clip_classifier/requirements.txt +10 -0
  260. package/ml/clip_classifier/similarity_engine.py +74 -0
  261. package/new-logo.png +0 -0
  262. package/observability/alert-rules.yml +60 -0
  263. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  264. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  265. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  266. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  267. package/observability/loki-config.yml +38 -0
  268. package/observability/mysql-setup.sql +46 -0
  269. package/observability/prometheus.yml +35 -0
  270. package/observability/promtail-config.yml +84 -0
  271. package/observability/sticker-catalog-slo.md +83 -0
  272. package/observability/sticker-scale-hardening-rollout.md +128 -0
  273. package/package.json +144 -0
  274. package/public/apple-touch-icon.png +0 -0
  275. package/public/assets/css/commands-react.input.css +71 -0
  276. package/public/assets/css/create-pack-react.input.css +31 -0
  277. package/public/assets/css/home-react.input.css +106 -0
  278. package/public/assets/css/login-react.input.css +58 -0
  279. package/public/assets/css/stickers-react.input.css +18 -0
  280. package/public/assets/css/terms-react.input.css +115 -0
  281. package/public/assets/css/user-react.input.css +57 -0
  282. package/public/assets/images/brand-icon-192.png +0 -0
  283. package/public/assets/images/brand-logo-128.webp +0 -0
  284. package/public/assets/images/hero-banner-1280.jpg +0 -0
  285. package/public/comandos/commands-catalog.json +4517 -0
  286. package/public/css/api-docs.css +161 -0
  287. package/public/css/stickers-admin.css +1288 -0
  288. package/public/css/styles.css +679 -0
  289. package/public/css/systemadm/admin.css +474 -0
  290. package/public/css/systemadm/base.css +73 -0
  291. package/public/css/systemadm/components.css +662 -0
  292. package/public/css/systemadm/layout.css +229 -0
  293. package/public/css/systemadm/tokens.css +56 -0
  294. package/public/favicon-16x16.png +0 -0
  295. package/public/favicon-32x32.png +0 -0
  296. package/public/favicon.ico +0 -0
  297. package/public/js/apps/apiDocsApp.js +235 -0
  298. package/public/js/apps/commandsReactApp.js +528 -0
  299. package/public/js/apps/createPackApp.js +1646 -0
  300. package/public/js/apps/homeReactApp.js +942 -0
  301. package/public/js/apps/loginReactApp.js +496 -0
  302. package/public/js/apps/stickersAdminApp.js +1753 -0
  303. package/public/js/apps/stickersApp.js +3797 -0
  304. package/public/js/apps/termsReactApp.js +528 -0
  305. package/public/js/apps/userApp.js +2540 -0
  306. package/public/js/apps/userProfile/actions.js +66 -0
  307. package/public/js/apps/userReactApp.js +547 -0
  308. package/public/js/catalog.js +950 -0
  309. package/public/pages/api-docs.html +40 -0
  310. package/public/pages/aup.html +158 -0
  311. package/public/pages/comandos.html +41 -0
  312. package/public/pages/dpa.html +227 -0
  313. package/public/pages/home.html +45 -0
  314. package/public/pages/licenca.html +182 -0
  315. package/public/pages/login.html +40 -0
  316. package/public/pages/notice-and-takedown.html +234 -0
  317. package/public/pages/politica-de-privacidade.html +251 -0
  318. package/public/pages/seo-bot-whatsapp-para-grupo.html +350 -0
  319. package/public/pages/seo-bot-whatsapp-sem-programar.html +350 -0
  320. package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +350 -0
  321. package/public/pages/seo-como-criar-comandos-whatsapp.html +350 -0
  322. package/public/pages/seo-como-evitar-spam-no-whatsapp.html +350 -0
  323. package/public/pages/seo-como-moderar-grupo-whatsapp.html +350 -0
  324. package/public/pages/seo-como-organizar-comunidade-whatsapp.html +350 -0
  325. package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +350 -0
  326. package/public/pages/stickers-admin.html +31 -0
  327. package/public/pages/stickers-create.html +41 -0
  328. package/public/pages/stickers.html +45 -0
  329. package/public/pages/suboperadores.html +237 -0
  330. package/public/pages/termos-de-uso-texto-integral.html +241 -0
  331. package/public/pages/termos-de-uso.html +41 -0
  332. package/public/pages/user-password-reset.html +32 -0
  333. package/public/pages/user-systemadm.html +508 -0
  334. package/public/pages/user.html +39 -0
  335. package/public/robots.txt +9 -0
  336. package/public/site.webmanifest +24 -0
  337. package/public/sitemap.xml +98 -0
  338. package/schemas/command-config.schema.json +582 -0
  339. package/scripts/baileys-compat-smoke.mjs +12 -0
  340. package/scripts/cache-bust.mjs +142 -0
  341. package/scripts/deploy.sh +916 -0
  342. package/scripts/email-broadcast-terms-update.mjs +170 -0
  343. package/scripts/enrich-command-discovery-fields.mjs +286 -0
  344. package/scripts/generate-command-config-schema.mjs +273 -0
  345. package/scripts/generate-commands-catalog.mjs +308 -0
  346. package/scripts/generate-module-agents.mjs +631 -0
  347. package/scripts/generate-seo-satellite-pages.mjs +400 -0
  348. package/scripts/github-deploy-notify.mjs +174 -0
  349. package/scripts/github-release-notify.mjs +219 -0
  350. package/scripts/release.sh +599 -0
  351. package/scripts/run-codeql-local.sh +116 -0
  352. package/scripts/run-prettier-all.mjs +25 -0
  353. package/scripts/security-smoketest.mjs +581 -0
  354. package/scripts/sticker-catalog-loadtest.mjs +210 -0
  355. package/scripts/sticker-worker-task.mjs +119 -0
  356. package/scripts/sync-readme-snapshot.mjs +133 -0
  357. package/scripts/validate-command-config-schema.mjs +130 -0
  358. package/scripts/validate-command-configs.mjs +15 -0
  359. package/scripts/wiki-sync.sh +191 -0
  360. package/server/auth/googleWebAuth/googleWebAuthRuntime.js +62 -0
  361. package/server/auth/googleWebAuth/googleWebAuthService.js +807 -0
  362. package/server/auth/jwt/webJwtService.js +147 -0
  363. package/server/auth/stickerCatalogAuthContext.js +165 -0
  364. package/server/auth/termsAcceptance/termsAcceptanceHandler.js +189 -0
  365. package/server/auth/userPassword/index.js +14 -0
  366. package/server/auth/userPassword/userPasswordAuthService.js +422 -0
  367. package/server/auth/userPassword/userPasswordCrypto.js +199 -0
  368. package/server/auth/userPassword/userPasswordCrypto.test.js +76 -0
  369. package/server/auth/userPassword/userPasswordRecoveryService.js +728 -0
  370. package/server/auth/validation/authSchemas.js +236 -0
  371. package/server/auth/webAccount/webAccountHandlers.js +1434 -0
  372. package/server/controllers/admin/adminBanService.js +138 -0
  373. package/server/controllers/admin/adminPanelHandlers.js +2083 -0
  374. package/server/controllers/admin/stickerCatalogAdminContext.js +17 -0
  375. package/server/controllers/admin/systemAdminController.js +201 -0
  376. package/server/controllers/email/emailAutomationController.js +239 -0
  377. package/server/controllers/metricsController.js +21 -0
  378. package/server/controllers/seo/stickerCatalogSeoContext.js +514 -0
  379. package/server/controllers/sticker/nonCatalogHandlers.js +303 -0
  380. package/server/controllers/sticker/stickerCatalogController.js +4700 -0
  381. package/server/controllers/system/contactController.js +115 -0
  382. package/server/controllers/system/githubController.js +137 -0
  383. package/server/controllers/system/stickerCatalogSystemContext.js +758 -0
  384. package/server/controllers/system/storageController.js +154 -0
  385. package/server/controllers/system/systemController.js +135 -0
  386. package/server/controllers/system/systemMetricsController.js +156 -0
  387. package/server/controllers/system/visitController.js +90 -0
  388. package/server/controllers/userController.js +145 -0
  389. package/server/email/emailAutomationRuntime.js +225 -0
  390. package/server/email/emailAutomationService.js +125 -0
  391. package/server/email/emailOutboxRepository.js +282 -0
  392. package/server/email/emailTemplateService.js +480 -0
  393. package/server/email/emailTransportService.js +156 -0
  394. package/server/http/clientIp.js +95 -0
  395. package/server/http/httpRequestUtils.js +262 -0
  396. package/server/http/httpRequestUtils.test.js +80 -0
  397. package/server/http/httpServer.js +180 -0
  398. package/server/http/requestContext.js +20 -0
  399. package/server/http/siteRoutingUtils.js +87 -0
  400. package/server/index.js +1 -0
  401. package/server/middleware/cachePolicy.js +26 -0
  402. package/server/middleware/cachePolicyHelpers.js +1 -0
  403. package/server/middleware/endpointRateLimit.js +181 -0
  404. package/server/middleware/rateLimit.js +70 -0
  405. package/server/middleware/requireAdminAuth.js +48 -0
  406. package/server/middleware/securityHeaders.js +97 -0
  407. package/server/routes/admin/systemAdminRouter.js +64 -0
  408. package/server/routes/email/emailAutomationRouter.js +46 -0
  409. package/server/routes/health/healthRouter.js +41 -0
  410. package/server/routes/indexRouter.js +234 -0
  411. package/server/routes/metrics/metricsRouter.js +58 -0
  412. package/server/routes/static/staticPageRouter.js +134 -0
  413. package/server/routes/sticker/catalogHandlers/catalogAdminHttp.js +105 -0
  414. package/server/routes/sticker/catalogHandlers/catalogAuthHttp.js +77 -0
  415. package/server/routes/sticker/catalogHandlers/catalogPublicHttp.js +120 -0
  416. package/server/routes/sticker/catalogHandlers/catalogUploadHttp.js +83 -0
  417. package/server/routes/sticker/catalogRouter.js +77 -0
  418. package/server/routes/sticker/stickerApiRouter.js +84 -0
  419. package/server/routes/sticker/stickerDataRouter.js +145 -0
  420. package/server/routes/sticker/stickerSiteRouter.js +43 -0
  421. package/server/routes/user/userApiPaths.js +66 -0
  422. package/server/routes/user/userRouter.js +65 -0
  423. package/server/utils/safePath.js +26 -0
  424. package/utils/logger/loggerModule.js +35 -0
  425. package/vite.config.mjs +38 -0
@@ -0,0 +1,1753 @@
1
+ const root = document.getElementById('stickers-admin-root');
2
+
3
+ if (!root) {
4
+ throw new Error('stickers-admin-root nao encontrado.');
5
+ }
6
+
7
+ const apiBasePath = root.dataset.apiBasePath || '/api';
8
+ const webPath = root.dataset.webPath || '/stickers';
9
+ const adminApiBase = `${apiBasePath}/admin`;
10
+ const googleSessionApiPath = `${apiBasePath}/auth/google/session`;
11
+ const createConfigApiPath = `${apiBasePath}/create-config`;
12
+ const GOOGLE_GSI_SCRIPT_SRC = 'https://accounts.google.com/gsi/client';
13
+ const GOOGLE_AUTH_CACHE_KEY = 'omnizap_google_web_auth_cache_v1';
14
+
15
+ const PAGE_SIZE = Object.freeze({
16
+ sessions: 8,
17
+ users: 8,
18
+ packs: 10,
19
+ bans: 8,
20
+ uploads: 8,
21
+ });
22
+
23
+ const TAB_ITEMS = [
24
+ { id: 'users', label: 'Usuarios' },
25
+ { id: 'packs', label: 'Packs' },
26
+ { id: 'logs', label: 'Logs' },
27
+ { id: 'uploads', label: 'Uploads' },
28
+ { id: 'system', label: 'Sistema' },
29
+ ];
30
+
31
+ const state = {
32
+ loading: true,
33
+ busy: false,
34
+ sidebarOpen: false,
35
+ activeTab: 'users',
36
+ rowMenu: null,
37
+ adminStatus: null,
38
+ googleAuthConfig: { enabled: false, clientId: '' },
39
+ googleAuthConfigError: '',
40
+ googleLoginUiReady: false,
41
+ overview: null,
42
+ packs: [],
43
+ moderators: [],
44
+ selectedPackKey: '',
45
+ selectedPack: null,
46
+ packsQuery: '',
47
+ usersQuery: '',
48
+ logsQuery: '',
49
+ error: '',
50
+ toast: null,
51
+ pagination: {
52
+ sessions: 1,
53
+ users: 1,
54
+ packs: 1,
55
+ bans: 1,
56
+ uploads: 1,
57
+ },
58
+ };
59
+
60
+ let toastTimer = null;
61
+ let googleLoginRenderNonce = 0;
62
+
63
+ const escapeHtml = (value) =>
64
+ String(value ?? '')
65
+ .replace(/&/g, '&')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;')
69
+ .replace(/'/g, '&#39;');
70
+
71
+ const fmtNum = (value) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(Math.max(0, Number(value || 0)));
72
+
73
+ const fmtDate = (value) => {
74
+ const raw = String(value || '').trim();
75
+ if (!raw) return '-';
76
+ const dt = new Date(raw);
77
+ if (Number.isNaN(dt.getTime())) return raw;
78
+ return dt.toLocaleString('pt-BR');
79
+ };
80
+
81
+ const normalizeToken = (value) =>
82
+ String(value || '')
83
+ .trim()
84
+ .toLowerCase()
85
+ .normalize('NFD')
86
+ .replace(/[\u0300-\u036f]/g, '');
87
+
88
+ const includesToken = (parts, token) => {
89
+ if (!token) return true;
90
+ return parts.some((part) => normalizeToken(part).includes(token));
91
+ };
92
+
93
+ const isAdminAuthenticated = () => Boolean(state.adminStatus?.session?.authenticated);
94
+ const canUnlockAdmin = () => Boolean(state.adminStatus?.eligible_google_login);
95
+ const getAdminRole = () =>
96
+ String(state.adminStatus?.session?.role || '')
97
+ .trim()
98
+ .toLowerCase();
99
+ const canManageModerators = () => Boolean(state.adminStatus?.session?.capabilities?.can_manage_moderators || getAdminRole() === 'owner');
100
+
101
+ function setToast(type, message, timeoutMs = 3200) {
102
+ const clean = String(message || '').trim();
103
+ if (!clean) {
104
+ state.toast = null;
105
+ return;
106
+ }
107
+ state.toast = { type: String(type || 'info'), message: clean };
108
+ window.clearTimeout(toastTimer);
109
+ toastTimer = window.setTimeout(() => {
110
+ state.toast = null;
111
+ render();
112
+ }, timeoutMs);
113
+ }
114
+
115
+ function setError(message) {
116
+ state.error = String(message || '').trim();
117
+ if (state.error) setToast('error', state.error, 5000);
118
+ }
119
+
120
+ function clearError() {
121
+ state.error = '';
122
+ }
123
+
124
+ function setBusy(busy) {
125
+ state.busy = Boolean(busy);
126
+ }
127
+
128
+ function readLocalGoogleAuthCache() {
129
+ try {
130
+ const raw = localStorage.getItem(GOOGLE_AUTH_CACHE_KEY);
131
+ if (!raw) return null;
132
+ const parsed = JSON.parse(raw);
133
+ const auth = parsed?.auth && typeof parsed.auth === 'object' ? parsed.auth : null;
134
+ const user = auth?.user && typeof auth.user === 'object' ? auth.user : null;
135
+ const sub = String(user?.sub || '').trim();
136
+ if (!sub) return null;
137
+ return {
138
+ user: {
139
+ sub,
140
+ email: String(user?.email || '').trim(),
141
+ name: String(user?.name || '').trim() || 'Conta Google',
142
+ },
143
+ savedAt: Number(parsed?.savedAt || 0),
144
+ };
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function decodeJwtPayload(jwt) {
151
+ const parts = String(jwt || '').split('.');
152
+ if (parts.length < 2) return null;
153
+ try {
154
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
155
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
156
+ const decoded = atob(padded);
157
+ return JSON.parse(decoded);
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function loadScript(src) {
164
+ return new Promise((resolve, reject) => {
165
+ const existing = document.querySelector(`script[src="${src}"]`);
166
+ if (existing) {
167
+ if (existing.dataset.loaded === '1') {
168
+ resolve();
169
+ return;
170
+ }
171
+ existing.addEventListener('load', () => resolve(), { once: true });
172
+ existing.addEventListener('error', () => reject(new Error(`Falha ao carregar script: ${src}`)), { once: true });
173
+ return;
174
+ }
175
+
176
+ const script = document.createElement('script');
177
+ script.src = src;
178
+ script.async = true;
179
+ script.defer = true;
180
+ script.addEventListener('load', () => {
181
+ script.dataset.loaded = '1';
182
+ resolve();
183
+ });
184
+ script.addEventListener('error', () => reject(new Error(`Falha ao carregar script: ${src}`)));
185
+ document.head.appendChild(script);
186
+ });
187
+ }
188
+
189
+ async function fetchJson(url, options = {}) {
190
+ const response = await fetch(url, {
191
+ credentials: 'include',
192
+ ...options,
193
+ });
194
+
195
+ const text = await response.text().catch(() => '');
196
+ let payload = {};
197
+ if (text) {
198
+ try {
199
+ payload = JSON.parse(text);
200
+ } catch {
201
+ payload = { raw: text };
202
+ }
203
+ }
204
+
205
+ if (!response.ok) {
206
+ const error = new Error(payload?.error || payload?.message || `HTTP ${response.status}`);
207
+ error.status = response.status;
208
+ error.payload = payload;
209
+ throw error;
210
+ }
211
+
212
+ return payload;
213
+ }
214
+
215
+ const getOverviewData = () => {
216
+ const overview = state.overview || {};
217
+ return {
218
+ counters: overview?.counters && typeof overview.counters === 'object' ? overview.counters : {},
219
+ marketplace: overview?.marketplace_stats && typeof overview.marketplace_stats === 'object' ? overview.marketplace_stats : {},
220
+ activeSessions: Array.isArray(overview?.active_sessions) ? overview.active_sessions : [],
221
+ users: Array.isArray(overview?.users) ? overview.users : [],
222
+ bans: Array.isArray(overview?.bans) ? overview.bans : [],
223
+ recentPacks: Array.isArray(overview?.recent_packs) ? overview.recent_packs : [],
224
+ };
225
+ };
226
+
227
+ function paginate(items, key) {
228
+ const list = Array.isArray(items) ? items : [];
229
+ const size = Math.max(1, Number(PAGE_SIZE[key] || 10));
230
+ const totalPages = Math.max(1, Math.ceil(list.length / size));
231
+ const current = Math.min(totalPages, Math.max(1, Number(state.pagination[key] || 1)));
232
+ state.pagination[key] = current;
233
+ const start = (current - 1) * size;
234
+ return {
235
+ items: list.slice(start, start + size),
236
+ current,
237
+ totalPages,
238
+ total: list.length,
239
+ };
240
+ }
241
+
242
+ function renderPagination(key, page) {
243
+ if (!page || page.totalPages <= 1) return '';
244
+ const prevPage = Math.max(1, page.current - 1);
245
+ const nextPage = Math.min(page.totalPages, page.current + 1);
246
+ return `
247
+ <div class="pager">
248
+ <button class="subtle-btn" data-action="page-nav" data-page-target="${escapeHtml(key)}" data-page="${prevPage}" ${page.current <= 1 ? 'disabled' : ''}>Anterior</button>
249
+ <span class="pager-meta">Pagina ${page.current} de ${page.totalPages} • ${fmtNum(page.total)} itens</span>
250
+ <button class="subtle-btn" data-action="page-nav" data-page-target="${escapeHtml(key)}" data-page="${nextPage}" ${page.current >= page.totalPages ? 'disabled' : ''}>Proxima</button>
251
+ </div>
252
+ `;
253
+ }
254
+
255
+ function toneClassForStatus(status) {
256
+ const normalized = String(status || '')
257
+ .trim()
258
+ .toLowerCase();
259
+ if (['published', 'ready', 'done', 'online', 'active'].includes(normalized)) return 'status-success';
260
+ if (['failed', 'error', 'deleted'].includes(normalized)) return 'status-danger';
261
+ if (['uploading', 'processing', 'pending', 'draft'].includes(normalized)) return 'status-warning';
262
+ return 'status-neutral';
263
+ }
264
+
265
+ function renderStatusBadge(label, tone = '') {
266
+ const normalizedTone = tone || toneClassForStatus(label);
267
+ return `<span class="status-badge ${normalizedTone}">${escapeHtml(label || 'n/a')}</span>`;
268
+ }
269
+
270
+ function isRowMenuOpen(kind, id) {
271
+ return Boolean(state.rowMenu && state.rowMenu.kind === kind && String(state.rowMenu.id) === String(id));
272
+ }
273
+
274
+ function renderMenuWrap(kind, id, content) {
275
+ const open = isRowMenuOpen(kind, id);
276
+ return `
277
+ <div class="menu-wrap" data-row-menu>
278
+ <button class="menu-trigger" data-action="toggle-row-menu" data-row-kind="${escapeHtml(kind)}" data-row-id="${escapeHtml(id)}" aria-expanded="${open ? 'true' : 'false'}">⋯</button>
279
+ ${open ? `<div class="row-menu">${content}</div>` : ''}
280
+ </div>
281
+ `;
282
+ }
283
+
284
+ function renderSparkline(values) {
285
+ const source = Array.isArray(values) && values.length ? values.map((entry) => Math.max(0, Number(entry || 0))) : [0, 0, 0, 0, 0, 0, 0];
286
+ const max = Math.max(...source, 1);
287
+ const bars = source
288
+ .map((value) => {
289
+ const h = Math.max(4, Math.min(24, Math.round((value / max) * 24)));
290
+ return `<span class="sparkbar" style="height:${h}px"></span>`;
291
+ })
292
+ .join('');
293
+ return `<div class="sparkline">${bars}</div>`;
294
+ }
295
+
296
+ function buildMetricCards() {
297
+ const { counters, marketplace, activeSessions } = getOverviewData();
298
+ const series = Array.isArray(marketplace?.series_last_7_days) ? marketplace.series_last_7_days : [];
299
+
300
+ const clicksSeries = series.map((row) => Number(row?.clicks || 0));
301
+ const packsSeries = series.map((row) => Number(row?.packs_published || 0));
302
+ const likesSeries = series.map((row) => Number(row?.likes || 0));
303
+ const usersSeries = series.map((_, idx) => {
304
+ const base = Math.max(1, Number(counters.known_google_users || counters.known_users || 1));
305
+ return Math.max(0, Math.round((base * (idx + 3)) / 10));
306
+ });
307
+
308
+ return [
309
+ {
310
+ id: 'users',
311
+ label: 'Usuarios',
312
+ value: Number(counters.known_google_users || counters.known_users || 0),
313
+ trend: `${fmtNum(activeSessions.length)} online`,
314
+ trendTone: 'trend-neutral',
315
+ bars: usersSeries,
316
+ },
317
+ {
318
+ id: 'downloads',
319
+ label: 'Downloads',
320
+ value: Number(marketplace.total_clicks || 0),
321
+ trend: `+${fmtNum(marketplace.clicks_last_7_days || 0)} nos ultimos 7 dias`,
322
+ trendTone: 'trend-up',
323
+ bars: clicksSeries,
324
+ },
325
+ {
326
+ id: 'packs',
327
+ label: 'Packs',
328
+ value: Number(counters.total_packs_any_status || marketplace.total_packs || 0),
329
+ trend: `+${fmtNum(marketplace.packs_last_7_days || 0)} esta semana`,
330
+ trendTone: 'trend-up',
331
+ bars: packsSeries,
332
+ },
333
+ {
334
+ id: 'errors',
335
+ label: 'Incidentes',
336
+ value: Number(counters.active_bans || 0),
337
+ trend: `${fmtNum(counters.active_bans || 0)} bloqueios ativos`,
338
+ trendTone: Number(counters.active_bans || 0) > 0 ? 'trend-down' : 'trend-neutral',
339
+ bars: likesSeries,
340
+ },
341
+ ];
342
+ }
343
+
344
+ function renderMetricCards() {
345
+ const cards = buildMetricCards();
346
+ return `
347
+ <section class="metrics-grid">
348
+ ${cards
349
+ .map(
350
+ (card) => `
351
+ <article class="metric-card">
352
+ <p class="metric-label">${escapeHtml(card.label)}</p>
353
+ <p class="metric-value">${fmtNum(card.value)}</p>
354
+ <div class="metric-foot">
355
+ <span class="metric-trend ${escapeHtml(card.trendTone)}">${escapeHtml(card.trend)}</span>
356
+ ${renderSparkline(card.bars)}
357
+ </div>
358
+ </article>
359
+ `,
360
+ )
361
+ .join('')}
362
+ </section>
363
+ `;
364
+ }
365
+
366
+ function renderTabs() {
367
+ return `
368
+ <div class="tabs-strip">
369
+ ${TAB_ITEMS.map((tab) => {
370
+ const active = state.activeTab === tab.id;
371
+ return `<button class="tab-btn ${active ? 'active' : ''}" data-action="switch-tab" data-tab-id="${escapeHtml(tab.id)}">${escapeHtml(tab.label)}</button>`;
372
+ }).join('')}
373
+ </div>
374
+ `;
375
+ }
376
+
377
+ function renderUsersTab() {
378
+ const { activeSessions, users } = getOverviewData();
379
+ const token = normalizeToken(state.usersQuery);
380
+
381
+ const filteredSessions = activeSessions.filter((row) => includesToken([row?.name, row?.email, row?.owner_jid, row?.google_sub], token));
382
+ const filteredUsers = users.filter((row) => includesToken([row?.name, row?.email, row?.owner_jid, row?.google_sub], token));
383
+
384
+ const sessionsPage = paginate(filteredSessions, 'sessions');
385
+ const usersPage = paginate(filteredUsers, 'users');
386
+
387
+ const sessionRowsDesktop = sessionsPage.items
388
+ .map((row) => {
389
+ const menu = renderMenuWrap('session', row?.session_token || row?.google_sub || row?.email || Math.random(), `<button class="row-menu-item danger" data-action="ban-user" data-email="${escapeHtml(row?.email || '')}" data-sub="${escapeHtml(row?.google_sub || '')}" data-owner="${escapeHtml(row?.owner_jid || '')}">Banir usuario</button>`);
390
+ return `
391
+ <tr>
392
+ <td>
393
+ <div class="row-title">${escapeHtml(row?.name || 'Conta Google')}</div>
394
+ <div class="row-sub break-all">${escapeHtml(row?.email || '-')}</div>
395
+ <div class="row-meta break-all">${escapeHtml(row?.owner_jid || '')}</div>
396
+ </td>
397
+ <td class="muted">${escapeHtml(fmtDate(row?.last_seen_at || row?.created_at))}</td>
398
+ <td>${menu}</td>
399
+ </tr>
400
+ `;
401
+ })
402
+ .join('');
403
+
404
+ const usersRowsDesktop = usersPage.items
405
+ .map((row) => {
406
+ const menu = renderMenuWrap('user', row?.google_sub || row?.email || row?.owner_jid || Math.random(), `<button class="row-menu-item danger" data-action="ban-user" data-email="${escapeHtml(row?.email || '')}" data-sub="${escapeHtml(row?.google_sub || '')}" data-owner="${escapeHtml(row?.owner_jid || '')}">Banir usuario</button>`);
407
+ return `
408
+ <tr>
409
+ <td>
410
+ <div class="row-title">${escapeHtml(row?.name || 'Conta Google')}</div>
411
+ <div class="row-sub break-all">${escapeHtml(row?.email || '-')}</div>
412
+ <div class="row-meta break-all">${escapeHtml(row?.owner_jid || '')}</div>
413
+ <div class="row-meta break-all mono">${escapeHtml(row?.google_sub || '')}</div>
414
+ </td>
415
+ <td class="muted">${escapeHtml(fmtDate(row?.last_login_at || row?.last_seen_at || row?.updated_at))}</td>
416
+ <td>${menu}</td>
417
+ </tr>
418
+ `;
419
+ })
420
+ .join('');
421
+
422
+ const sessionsMobile = sessionsPage.items
423
+ .map(
424
+ (row) => `
425
+ <article class="mobile-card">
426
+ <p class="row-title">${escapeHtml(row?.name || 'Conta Google')}</p>
427
+ <p class="row-sub break-all">${escapeHtml(row?.email || '-')}</p>
428
+ <p class="row-meta break-all">${escapeHtml(row?.owner_jid || '')}</p>
429
+ <div class="mobile-card-foot">
430
+ <span class="muted">${escapeHtml(fmtDate(row?.last_seen_at || row?.created_at))}</span>
431
+ <button class="danger-btn" data-action="ban-user" data-email="${escapeHtml(row?.email || '')}" data-sub="${escapeHtml(row?.google_sub || '')}" data-owner="${escapeHtml(row?.owner_jid || '')}">Banir</button>
432
+ </div>
433
+ </article>
434
+ `,
435
+ )
436
+ .join('');
437
+
438
+ const usersMobile = usersPage.items
439
+ .map(
440
+ (row) => `
441
+ <article class="mobile-card">
442
+ <p class="row-title">${escapeHtml(row?.name || 'Conta Google')}</p>
443
+ <p class="row-sub break-all">${escapeHtml(row?.email || '-')}</p>
444
+ <p class="row-meta break-all">${escapeHtml(row?.owner_jid || '')}</p>
445
+ <p class="row-meta break-all mono">${escapeHtml(row?.google_sub || '')}</p>
446
+ <div class="mobile-card-foot">
447
+ <span class="muted">${escapeHtml(fmtDate(row?.last_login_at || row?.last_seen_at || row?.updated_at))}</span>
448
+ <button class="danger-btn" data-action="ban-user" data-email="${escapeHtml(row?.email || '')}" data-sub="${escapeHtml(row?.google_sub || '')}" data-owner="${escapeHtml(row?.owner_jid || '')}">Banir</button>
449
+ </div>
450
+ </article>
451
+ `,
452
+ )
453
+ .join('');
454
+
455
+ return `
456
+ <section class="stack">
457
+ <section class="panel">
458
+ <div class="panel-head">
459
+ <div>
460
+ <h3 class="panel-title">Usuarios</h3>
461
+ <p class="panel-desc">Monitore sessoes ativas e contas vinculadas ao marketplace.</p>
462
+ </div>
463
+ <form data-form="users-search" class="search-form compact">
464
+ <input class="search-input" type="search" name="q" placeholder="Buscar por nome, email, owner_jid" value="${escapeHtml(state.usersQuery)}" />
465
+ <button class="outline-btn" type="submit">Filtrar</button>
466
+ </form>
467
+ </div>
468
+ </section>
469
+
470
+ <div class="section-grid two-col">
471
+ <section class="panel">
472
+ <div class="panel-head slim">
473
+ <h4 class="panel-title">Sessoes ativas (${fmtNum(sessionsPage.total)})</h4>
474
+ </div>
475
+ <div class="table-shell desktop-only">
476
+ <table class="data-table">
477
+ <thead>
478
+ <tr><th>Usuario</th><th>Ultimo acesso</th><th>Acoes</th></tr>
479
+ </thead>
480
+ <tbody>
481
+ ${sessionRowsDesktop || '<tr><td colspan="3" class="empty">Nenhuma sessao ativa.</td></tr>'}
482
+ </tbody>
483
+ </table>
484
+ </div>
485
+ <div class="mobile-list mobile-only">${sessionsMobile || '<div class="empty-box">Nenhuma sessao ativa.</div>'}</div>
486
+ ${renderPagination('sessions', sessionsPage)}
487
+ </section>
488
+
489
+ <section class="panel">
490
+ <div class="panel-head slim">
491
+ <h4 class="panel-title">Usuarios conhecidos (${fmtNum(usersPage.total)})</h4>
492
+ </div>
493
+ <div class="table-shell desktop-only">
494
+ <table class="data-table">
495
+ <thead>
496
+ <tr><th>Usuario</th><th>Ultimo login</th><th>Acoes</th></tr>
497
+ </thead>
498
+ <tbody>
499
+ ${usersRowsDesktop || '<tr><td colspan="3" class="empty">Sem usuarios cadastrados.</td></tr>'}
500
+ </tbody>
501
+ </table>
502
+ </div>
503
+ <div class="mobile-list mobile-only">${usersMobile || '<div class="empty-box">Sem usuarios cadastrados.</div>'}</div>
504
+ ${renderPagination('users', usersPage)}
505
+ </section>
506
+ </div>
507
+ </section>
508
+ `;
509
+ }
510
+
511
+ function renderPacksTab() {
512
+ const packsPage = paginate(state.packs, 'packs');
513
+ const selectedData = state.selectedPack?.pack ? state.selectedPack : state.selectedPack?.data || state.selectedPack;
514
+ const selectedPack = selectedData?.pack || null;
515
+ const selectedItems = Array.isArray(selectedPack?.items) ? selectedPack.items : [];
516
+
517
+ const packRowsDesktop = packsPage.items
518
+ .map((pack) => {
519
+ const statusBadges = [renderStatusBadge(pack?.visibility || 'n/a'), renderStatusBadge(pack?.status || 'n/a'), renderStatusBadge(pack?.pack_status || 'ready')].join('');
520
+
521
+ const menu = renderMenuWrap(
522
+ 'pack',
523
+ pack?.pack_key || pack?.id || Math.random(),
524
+ `
525
+ <button class="row-menu-item" data-action="open-pack-admin" data-pack-key="${escapeHtml(pack?.pack_key || '')}">Ver detalhes</button>
526
+ <a class="row-menu-item" href="${escapeHtml(pack?.web_url || `${webPath}/${encodeURIComponent(String(pack?.pack_key || ''))}`)}" target="_blank" rel="noreferrer">Abrir no catalogo</a>
527
+ <button class="row-menu-item danger" data-action="delete-pack-admin" data-pack-key="${escapeHtml(pack?.pack_key || '')}">Apagar pack</button>
528
+ `,
529
+ );
530
+
531
+ return `
532
+ <tr>
533
+ <td>
534
+ <div class="row-title break-words">${escapeHtml(pack?.name || pack?.pack_key || 'Pack')}</div>
535
+ <div class="row-sub break-all">${escapeHtml(pack?.pack_key || '')}</div>
536
+ <div class="row-meta break-all">${escapeHtml(pack?.owner_jid || '')}</div>
537
+ </td>
538
+ <td>${statusBadges}</td>
539
+ <td class="muted">${fmtNum(pack?.stickers_count)} stickers • ${fmtNum(pack?.like_count)} likes • ${fmtNum(pack?.open_count)} opens</td>
540
+ <td>${menu}</td>
541
+ </tr>
542
+ `;
543
+ })
544
+ .join('');
545
+
546
+ const packRowsMobile = packsPage.items
547
+ .map(
548
+ (pack) => `
549
+ <article class="mobile-card">
550
+ <p class="row-title break-words">${escapeHtml(pack?.name || pack?.pack_key || 'Pack')}</p>
551
+ <p class="row-sub break-all">${escapeHtml(pack?.pack_key || '')}</p>
552
+ <p class="row-meta break-all">${escapeHtml(pack?.owner_jid || '')}</p>
553
+ <div class="badge-row">
554
+ ${renderStatusBadge(pack?.visibility || 'n/a')}
555
+ ${renderStatusBadge(pack?.status || 'n/a')}
556
+ ${renderStatusBadge(pack?.pack_status || 'ready')}
557
+ </div>
558
+ <p class="row-meta">${fmtNum(pack?.stickers_count)} stickers • ${fmtNum(pack?.like_count)} likes • ${fmtNum(pack?.open_count)} opens</p>
559
+ <div class="mobile-card-foot">
560
+ <button class="outline-btn" data-action="open-pack-admin" data-pack-key="${escapeHtml(pack?.pack_key || '')}">Detalhes</button>
561
+ <a class="outline-btn" href="${escapeHtml(pack?.web_url || `${webPath}/${encodeURIComponent(String(pack?.pack_key || ''))}`)}" target="_blank" rel="noreferrer">Abrir</a>
562
+ <button class="danger-btn" data-action="delete-pack-admin" data-pack-key="${escapeHtml(pack?.pack_key || '')}">Apagar</button>
563
+ </div>
564
+ </article>
565
+ `,
566
+ )
567
+ .join('');
568
+
569
+ const detailItems = selectedItems
570
+ .map(
571
+ (item) => `
572
+ <article class="detail-item">
573
+ <img class="detail-thumb" src="${escapeHtml(item?.asset_url || '')}" alt="" />
574
+ <div class="detail-content">
575
+ <p class="row-title break-all">${escapeHtml(item?.sticker_id || '')}</p>
576
+ <p class="row-meta">Posicao ${escapeHtml(item?.position)}</p>
577
+ <p class="row-meta">${escapeHtml(item?.asset?.mimetype || '')}</p>
578
+ </div>
579
+ <div class="detail-actions">
580
+ <button class="outline-btn" data-action="remove-pack-sticker" data-pack-key="${escapeHtml(selectedPack?.pack_key || '')}" data-sticker-id="${escapeHtml(item?.sticker_id || '')}">Remover do pack</button>
581
+ <button class="danger-btn" data-action="delete-sticker-global-btn" data-sticker-id="${escapeHtml(item?.sticker_id || '')}">Apagar global</button>
582
+ </div>
583
+ </article>
584
+ `,
585
+ )
586
+ .join('');
587
+
588
+ return `
589
+ <section class="stack">
590
+ <section class="panel">
591
+ <div class="panel-head">
592
+ <div>
593
+ <h3 class="panel-title">Packs</h3>
594
+ <p class="panel-desc">Moderacao completa de packs com busca e acoes rapidas.</p>
595
+ </div>
596
+ <form data-form="packs-search" class="search-form">
597
+ <input class="search-input" type="search" name="q" placeholder="pack_key, nome, publisher, owner_jid" value="${escapeHtml(state.packsQuery)}" />
598
+ <button class="primary-btn" type="submit">Buscar</button>
599
+ </form>
600
+ </div>
601
+
602
+ <div class="table-shell desktop-only">
603
+ <table class="data-table">
604
+ <thead>
605
+ <tr><th>Pack</th><th>Status</th><th>Metricas</th><th>Acoes</th></tr>
606
+ </thead>
607
+ <tbody>
608
+ ${packRowsDesktop || '<tr><td colspan="4" class="empty">Nenhum pack encontrado.</td></tr>'}
609
+ </tbody>
610
+ </table>
611
+ </div>
612
+ <div class="mobile-list mobile-only">${packRowsMobile || '<div class="empty-box">Nenhum pack encontrado.</div>'}</div>
613
+ ${renderPagination('packs', packsPage)}
614
+ </section>
615
+
616
+ <section class="panel">
617
+ <div class="panel-head slim">
618
+ <h4 class="panel-title">Pack selecionado</h4>
619
+ </div>
620
+ ${
621
+ selectedPack
622
+ ? `
623
+ <div class="selected-pack-head">
624
+ <div>
625
+ <p class="row-title break-words">${escapeHtml(selectedPack?.name || selectedPack?.pack_key || 'Pack')}</p>
626
+ <p class="row-sub break-all">${escapeHtml(selectedPack?.pack_key || '')}</p>
627
+ <p class="row-meta break-all">${escapeHtml(selectedPack?.owner_jid || '')}</p>
628
+ <div class="badge-row">
629
+ ${renderStatusBadge(selectedPack?.visibility || 'n/a')}
630
+ ${renderStatusBadge(selectedPack?.status || 'n/a')}
631
+ ${renderStatusBadge(selectedPack?.pack_status || 'ready')}
632
+ </div>
633
+ </div>
634
+ <div class="selected-pack-actions">
635
+ <a class="outline-btn" href="${escapeHtml(selectedPack?.web_url || `${webPath}/${encodeURIComponent(String(selectedPack?.pack_key || ''))}`)}" target="_blank" rel="noreferrer">Abrir</a>
636
+ <button class="danger-btn" data-action="delete-pack-admin" data-pack-key="${escapeHtml(selectedPack?.pack_key || '')}">Apagar pack</button>
637
+ <button class="outline-btn" data-action="ban-user" data-email="${escapeHtml(selectedPack?.owner_email || '')}" data-owner="${escapeHtml(selectedPack?.owner_jid || '')}">Banir dono</button>
638
+ </div>
639
+ </div>
640
+ <div class="detail-list">${detailItems || '<div class="empty-box">Pack sem stickers.</div>'}</div>
641
+ `
642
+ : '<div class="empty-box">Selecione um pack na tabela para visualizar e moderar stickers.</div>'
643
+ }
644
+ </section>
645
+ </section>
646
+ `;
647
+ }
648
+
649
+ function renderLogsTab() {
650
+ const { bans } = getOverviewData();
651
+ const token = normalizeToken(state.logsQuery);
652
+ const filteredBans = bans.filter((ban) => includesToken([ban?.email, ban?.owner_jid, ban?.google_sub, ban?.reason], token));
653
+ const page = paginate(filteredBans, 'bans');
654
+
655
+ const rowsDesktop = page.items
656
+ .map((ban) => {
657
+ const revoked = Boolean(ban?.revoked_at);
658
+ const menu = revoked ? '' : renderMenuWrap('ban', ban?.id || Math.random(), `<button class="row-menu-item" data-action="revoke-ban" data-ban-id="${escapeHtml(ban?.id || '')}">Revogar banimento</button>`);
659
+
660
+ return `
661
+ <tr>
662
+ <td>
663
+ <div class="row-title break-all">${escapeHtml(ban?.email || ban?.owner_jid || ban?.google_sub || ban?.id || 'Ban')}</div>
664
+ <div class="row-meta break-all">${escapeHtml(ban?.google_sub || '')}</div>
665
+ <div class="row-meta break-all">${escapeHtml(ban?.owner_jid || '')}</div>
666
+ </td>
667
+ <td class="muted">${escapeHtml(ban?.reason || 'Sem motivo')}</td>
668
+ <td class="muted">${escapeHtml(fmtDate(ban?.created_at))}</td>
669
+ <td>${revoked ? renderStatusBadge('revogado', 'status-neutral') : renderStatusBadge('ativo', 'status-danger')}</td>
670
+ <td>${menu || '<span class="muted">-</span>'}</td>
671
+ </tr>
672
+ `;
673
+ })
674
+ .join('');
675
+
676
+ const rowsMobile = page.items
677
+ .map(
678
+ (ban) => `
679
+ <article class="mobile-card">
680
+ <p class="row-title break-all">${escapeHtml(ban?.email || ban?.owner_jid || ban?.google_sub || ban?.id || 'Ban')}</p>
681
+ <p class="row-sub">${escapeHtml(ban?.reason || 'Sem motivo')}</p>
682
+ <p class="row-meta">${escapeHtml(fmtDate(ban?.created_at))}</p>
683
+ <div class="mobile-card-foot">
684
+ ${ban?.revoked_at ? renderStatusBadge('revogado', 'status-neutral') : `<button class="outline-btn" data-action="revoke-ban" data-ban-id="${escapeHtml(ban?.id || '')}">Revogar</button>`}
685
+ </div>
686
+ </article>
687
+ `,
688
+ )
689
+ .join('');
690
+
691
+ return `
692
+ <section class="stack">
693
+ <section class="panel">
694
+ <div class="panel-head">
695
+ <div>
696
+ <h3 class="panel-title">Logs e bans</h3>
697
+ <p class="panel-desc">Historico de bloqueios e auditoria de moderacao.</p>
698
+ </div>
699
+ <form data-form="logs-search" class="search-form compact">
700
+ <input class="search-input" type="search" name="q" placeholder="Buscar email, owner_jid, motivo" value="${escapeHtml(state.logsQuery)}" />
701
+ <button class="outline-btn" type="submit">Filtrar</button>
702
+ </form>
703
+ </div>
704
+
705
+ <div class="table-shell desktop-only">
706
+ <table class="data-table">
707
+ <thead>
708
+ <tr><th>Identidade</th><th>Motivo</th><th>Criado em</th><th>Status</th><th>Acoes</th></tr>
709
+ </thead>
710
+ <tbody>
711
+ ${rowsDesktop || '<tr><td colspan="5" class="empty">Sem registros de log.</td></tr>'}
712
+ </tbody>
713
+ </table>
714
+ </div>
715
+
716
+ <div class="mobile-list mobile-only">${rowsMobile || '<div class="empty-box">Sem registros de log.</div>'}</div>
717
+ ${renderPagination('bans', page)}
718
+ </section>
719
+
720
+ <section class="panel">
721
+ <div class="panel-head slim">
722
+ <h4 class="panel-title">Banir usuario manualmente</h4>
723
+ </div>
724
+ <form data-form="manual-ban" class="form-grid">
725
+ <input class="search-input" name="email" placeholder="email@exemplo.com" />
726
+ <input class="search-input" name="google_sub" placeholder="google_sub (opcional)" />
727
+ <input class="search-input" name="owner_jid" placeholder="owner_jid (opcional)" />
728
+ <input class="search-input" name="reason" placeholder="Motivo do banimento" />
729
+ <button class="danger-btn" type="submit">Banir agora</button>
730
+ </form>
731
+ </section>
732
+ </section>
733
+ `;
734
+ }
735
+
736
+ function renderUploadsTab() {
737
+ const { recentPacks } = getOverviewData();
738
+ const page = paginate(recentPacks, 'uploads');
739
+
740
+ const cards = page.items
741
+ .map((pack) => {
742
+ const status = String(pack?.status || '').toLowerCase();
743
+ const progressMap = {
744
+ published: 100,
745
+ processing: 72,
746
+ uploading: 48,
747
+ draft: 28,
748
+ failed: 100,
749
+ };
750
+ const progress = Number(progressMap[status] || 36);
751
+ const uploadMenu = renderMenuWrap(
752
+ 'upload',
753
+ pack?.pack_key || pack?.id || Math.random(),
754
+ `
755
+ <button class="row-menu-item" data-action="open-pack-admin" data-pack-key="${escapeHtml(pack?.pack_key || '')}">Ver detalhes</button>
756
+ <a class="row-menu-item" href="${escapeHtml(pack?.web_url || `${webPath}/${encodeURIComponent(String(pack?.pack_key || ''))}`)}" target="_blank" rel="noreferrer">Abrir no catalogo</a>
757
+ `,
758
+ );
759
+ return `
760
+ <article class="upload-card">
761
+ <div class="upload-card-head">
762
+ <div>
763
+ <p class="row-title break-words">${escapeHtml(pack?.name || pack?.pack_key || 'Pack')}</p>
764
+ <p class="row-sub break-all">${escapeHtml(pack?.pack_key || '')}</p>
765
+ </div>
766
+ ${renderStatusBadge(pack?.status || 'draft')}
767
+ </div>
768
+ <div class="progress-wrap">
769
+ <div class="progress-track"><span class="progress-bar" style="width:${Math.max(0, Math.min(100, progress))}%"></span></div>
770
+ <span class="progress-meta">${progress}%</span>
771
+ </div>
772
+ <div class="upload-meta">
773
+ <span>${fmtNum(pack?.sticker_count)} stickers</span>
774
+ <span>${fmtDate(pack?.updated_at || pack?.created_at)}</span>
775
+ </div>
776
+ <div class="upload-actions">
777
+ <button class="outline-btn" data-action="open-pack-admin" data-pack-key="${escapeHtml(pack?.pack_key || '')}">Ver pack</button>
778
+ ${uploadMenu}
779
+ </div>
780
+ </article>
781
+ `;
782
+ })
783
+ .join('');
784
+
785
+ return `
786
+ <section class="stack">
787
+ <section class="panel">
788
+ <div class="panel-head slim">
789
+ <h3 class="panel-title">Uploads e processamento</h3>
790
+ <p class="panel-desc">Visao compacta dos packs recentes e estado de processamento.</p>
791
+ </div>
792
+ <div class="upload-grid">
793
+ ${cards || '<div class="empty-box">Sem uploads recentes.</div>'}
794
+ </div>
795
+ ${renderPagination('uploads', page)}
796
+ </section>
797
+
798
+ <section class="panel">
799
+ <div class="panel-head slim">
800
+ <h4 class="panel-title">Apagar sticker global</h4>
801
+ <p class="panel-desc">Remove o sticker de todos os packs e limpa o asset quando ficar orfao.</p>
802
+ </div>
803
+ <form data-form="delete-sticker-global" class="search-form compact">
804
+ <input class="search-input" name="sticker_id" placeholder="sticker_id (UUID)" />
805
+ <button class="danger-btn" type="submit">Apagar</button>
806
+ </form>
807
+ </section>
808
+ </section>
809
+ `;
810
+ }
811
+
812
+ function renderSystemTab() {
813
+ const { counters, marketplace, users } = getOverviewData();
814
+ const authGoogle = state.adminStatus?.google || {};
815
+ const moderators = Array.isArray(state.moderators) ? state.moderators : [];
816
+ const ownerMode = canManageModerators();
817
+
818
+ const moderatorRowsDesktop = moderators
819
+ .map((row) => {
820
+ const active = Boolean(row?.active && !row?.revoked_at);
821
+ return `
822
+ <tr>
823
+ <td>
824
+ <div class="row-title">${escapeHtml(row?.name || 'Moderador')}</div>
825
+ <div class="row-sub break-all">${escapeHtml(row?.email || '-')}</div>
826
+ <div class="row-meta break-all mono">${escapeHtml(row?.google_sub || '')}</div>
827
+ <div class="row-meta break-all">${escapeHtml(row?.owner_jid || '')}</div>
828
+ </td>
829
+ <td>${active ? renderStatusBadge('ativo', 'status-success') : renderStatusBadge('revogado', 'status-neutral')}</td>
830
+ <td class="muted">${escapeHtml(fmtDate(row?.last_login_at || row?.updated_at || row?.created_at))}</td>
831
+ <td>
832
+ ${active ? `<button class="danger-btn" data-action="revoke-moderator" data-google-sub="${escapeHtml(row?.google_sub || '')}">Remover</button>` : '<span class="muted">-</span>'}
833
+ </td>
834
+ </tr>
835
+ `;
836
+ })
837
+ .join('');
838
+
839
+ const moderatorRowsMobile = moderators
840
+ .map(
841
+ (row) => `
842
+ <article class="mobile-card">
843
+ <p class="row-title">${escapeHtml(row?.name || 'Moderador')}</p>
844
+ <p class="row-sub break-all">${escapeHtml(row?.email || '-')}</p>
845
+ <p class="row-meta break-all mono">${escapeHtml(row?.google_sub || '')}</p>
846
+ <p class="row-meta break-all">${escapeHtml(row?.owner_jid || '')}</p>
847
+ <div class="mobile-card-foot">
848
+ ${row?.active && !row?.revoked_at ? renderStatusBadge('ativo', 'status-success') : renderStatusBadge('revogado', 'status-neutral')}
849
+ ${row?.active && !row?.revoked_at ? `<button class="danger-btn" data-action="revoke-moderator" data-google-sub="${escapeHtml(row?.google_sub || '')}">Remover</button>` : ''}
850
+ </div>
851
+ </article>
852
+ `,
853
+ )
854
+ .join('');
855
+
856
+ const targetOptions = (Array.isArray(users) ? users : [])
857
+ .slice(0, 120)
858
+ .map((row) => {
859
+ const email = String(row?.email || '').trim();
860
+ const sub = String(row?.google_sub || '').trim();
861
+ const owner = String(row?.owner_jid || '').trim();
862
+ const name = String(row?.name || '').trim();
863
+ const entries = [];
864
+ if (email) entries.push(`<option value="${escapeHtml(email)}">${escapeHtml(name || email)}</option>`);
865
+ if (sub) entries.push(`<option value="${escapeHtml(sub)}">${escapeHtml(name || sub)}</option>`);
866
+ if (owner) entries.push(`<option value="${escapeHtml(owner)}">${escapeHtml(name || owner)}</option>`);
867
+ return entries.join('');
868
+ })
869
+ .join('');
870
+
871
+ return `
872
+ <section class="stack">
873
+ <section class="section-grid two-col">
874
+ <article class="panel">
875
+ <div class="panel-head slim">
876
+ <h3 class="panel-title">Resumo do sistema</h3>
877
+ </div>
878
+ <div class="kv-grid">
879
+ <div class="kv-item"><span class="kv-key">Packs totais</span><span class="kv-value">${fmtNum(counters.total_packs_any_status || marketplace.total_packs)}</span></div>
880
+ <div class="kv-item"><span class="kv-key">Stickers totais</span><span class="kv-value">${fmtNum(counters.total_stickers_any_status || marketplace.total_stickers)}</span></div>
881
+ <div class="kv-item"><span class="kv-key">Cliques globais</span><span class="kv-value">${fmtNum(marketplace.total_clicks)}</span></div>
882
+ <div class="kv-item"><span class="kv-key">Likes globais</span><span class="kv-value">${fmtNum(marketplace.total_likes)}</span></div>
883
+ <div class="kv-item"><span class="kv-key">Atualizado em</span><span class="kv-value">${escapeHtml(fmtDate(marketplace.updated_at))}</span></div>
884
+ <div class="kv-item"><span class="kv-key">Sessoes ativas</span><span class="kv-value">${fmtNum(counters.active_google_sessions)}</span></div>
885
+ </div>
886
+ </article>
887
+
888
+ <article class="panel">
889
+ <div class="panel-head slim">
890
+ <h3 class="panel-title">Autenticacao admin</h3>
891
+ </div>
892
+ <div class="kv-grid">
893
+ <div class="kv-item"><span class="kv-key">Login provider</span><span class="kv-value">${escapeHtml(state.adminStatus?.session?.authenticated ? 'Google + senha' : 'Google')}</span></div>
894
+ <div class="kv-item"><span class="kv-key">Google session</span><span class="kv-value">${authGoogle?.authenticated ? 'Ativa' : 'Inativa'}</span></div>
895
+ <div class="kv-item"><span class="kv-key">Elegivel</span><span class="kv-value">${canUnlockAdmin() ? 'Sim' : 'Nao'}</span></div>
896
+ <div class="kv-item"><span class="kv-key">Seu papel</span><span class="kv-value">${escapeHtml(getAdminRole() || 'owner')}</span></div>
897
+ <div class="kv-item"><span class="kv-key">API base</span><span class="kv-value break-all mono">${escapeHtml(apiBasePath)}</span></div>
898
+ <div class="kv-item"><span class="kv-key">Google client</span><span class="kv-value break-all mono">${escapeHtml(state.googleAuthConfig?.clientId || '-')}</span></div>
899
+ </div>
900
+ </article>
901
+ </section>
902
+
903
+ <article class="panel">
904
+ <div class="panel-head slim">
905
+ <h3 class="panel-title">Moderadores</h3>
906
+ <p class="panel-desc">Acesso secundario do painel com senha individual e sessao Google obrigatoria.</p>
907
+ </div>
908
+ ${
909
+ ownerMode
910
+ ? `
911
+ <form data-form="moderator-upsert" class="form-grid">
912
+ <input class="search-input" list="moderator-target-list" name="target" placeholder="email, google_sub ou owner_jid do usuario logado" />
913
+ <datalist id="moderator-target-list">${targetOptions}</datalist>
914
+ <input class="search-input" type="password" name="password" placeholder="Senha do moderador" autocomplete="new-password" />
915
+ <button class="primary-btn" type="submit">Salvar moderador</button>
916
+ <p class="hint">Somente usuarios que ja fizeram login Google no site podem virar moderadores.</p>
917
+ </form>
918
+
919
+ <div class="table-shell desktop-only">
920
+ <table class="data-table">
921
+ <thead>
922
+ <tr><th>Usuario</th><th>Status</th><th>Ultimo login</th><th>Acoes</th></tr>
923
+ </thead>
924
+ <tbody>
925
+ ${moderatorRowsDesktop || '<tr><td colspan="4" class="empty">Nenhum moderador cadastrado.</td></tr>'}
926
+ </tbody>
927
+ </table>
928
+ </div>
929
+ <div class="mobile-list mobile-only">${moderatorRowsMobile || '<div class="empty-box">Nenhum moderador cadastrado.</div>'}</div>
930
+ `
931
+ : '<div class="empty-box">Sessao atual de moderador. Apenas o owner pode cadastrar/remover moderadores.</div>'
932
+ }
933
+ </article>
934
+
935
+ <article class="panel">
936
+ <div class="panel-head slim">
937
+ <h3 class="panel-title">Snapshot marketplace</h3>
938
+ <p class="panel-desc">Dados consolidados para auditoria rapida.</p>
939
+ </div>
940
+ <pre class="code-block">${escapeHtml(JSON.stringify({ counters, marketplace }, null, 2))}</pre>
941
+ </article>
942
+ </section>
943
+ `;
944
+ }
945
+
946
+ function renderActiveTabContent() {
947
+ if (state.activeTab === 'users') return renderUsersTab();
948
+ if (state.activeTab === 'packs') return renderPacksTab();
949
+ if (state.activeTab === 'logs') return renderLogsTab();
950
+ if (state.activeTab === 'uploads') return renderUploadsTab();
951
+ return renderSystemTab();
952
+ }
953
+
954
+ function renderSidebar({ mobile = false } = {}) {
955
+ const { counters } = getOverviewData();
956
+ const countMap = {
957
+ users: Number(counters.known_google_users || counters.known_users || 0),
958
+ packs: Number(counters.total_packs_any_status || 0),
959
+ logs: Number(counters.active_bans || 0),
960
+ uploads: Number((getOverviewData().recentPacks || []).length || 0),
961
+ system: Number(counters.active_google_sessions || 0),
962
+ };
963
+
964
+ const nav = `
965
+ <nav class="nav-group">
966
+ ${TAB_ITEMS.map((tab) => {
967
+ const active = state.activeTab === tab.id;
968
+ const count = countMap[tab.id] || 0;
969
+ return `
970
+ <button class="nav-item ${active ? 'active' : ''}" data-action="switch-tab" data-tab-id="${escapeHtml(tab.id)}">
971
+ <span>${escapeHtml(tab.label)}</span>
972
+ <span class="nav-count">${fmtNum(count)}</span>
973
+ </button>
974
+ `;
975
+ }).join('')}
976
+ </nav>
977
+ `;
978
+
979
+ if (!mobile) {
980
+ return `
981
+ <aside class="sidebar desktop-only">
982
+ ${nav}
983
+ <div class="sidebar-footer">
984
+ <button class="subtle-btn" data-action="refresh-dashboard" ${state.busy ? 'disabled' : ''}>Atualizar dados</button>
985
+ ${isAdminAuthenticated() ? `<button class="subtle-btn danger" data-action="logout-admin" ${state.busy ? 'disabled' : ''}>Sair do admin</button>` : ''}
986
+ </div>
987
+ </aside>
988
+ `;
989
+ }
990
+
991
+ if (!state.sidebarOpen) return '';
992
+
993
+ return `
994
+ <div class="drawer" data-drawer>
995
+ <div class="drawer-backdrop" data-action="close-sidebar"></div>
996
+ <aside class="drawer-panel">
997
+ <div class="drawer-head">
998
+ <p class="panel-title">Navegacao</p>
999
+ <button class="icon-btn" data-action="close-sidebar">✕</button>
1000
+ </div>
1001
+ ${nav}
1002
+ </aside>
1003
+ </div>
1004
+ `;
1005
+ }
1006
+
1007
+ function renderHeader() {
1008
+ const adminUser = state.adminStatus?.session?.user || null;
1009
+ const adminRole = getAdminRole();
1010
+ const roleLabel = adminRole === 'owner' ? 'owner' : adminRole === 'moderator' ? 'moderador' : '';
1011
+ return `
1012
+ <header class="topbar">
1013
+ <div class="topbar-inner">
1014
+ <div class="topbar-left">
1015
+ <button class="icon-btn mobile-only" data-action="toggle-sidebar" aria-label="Abrir menu">☰</button>
1016
+ <span class="brand-dot"></span>
1017
+ <div>
1018
+ <p class="brand-title">OmniZap Admin</p>
1019
+ <p class="brand-sub">Painel SaaS • Moderacao</p>
1020
+ </div>
1021
+ </div>
1022
+ <div class="topbar-actions">
1023
+ <button class="ghost-btn" data-action="refresh-dashboard" ${state.busy ? 'disabled' : ''}>Atualizar</button>
1024
+ ${isAdminAuthenticated() ? `<span class="user-chip">${escapeHtml(`${roleLabel ? `${roleLabel} • ` : ''}${adminUser?.email || adminUser?.name || 'Admin'}`)}</span>` : '<span class="user-chip muted">Nao autenticado</span>'}
1025
+ ${isAdminAuthenticated() ? `<button class="ghost-btn danger" data-action="logout-admin" ${state.busy ? 'disabled' : ''}>Sair</button>` : `<button class="ghost-btn" data-action="refresh-admin-status" ${state.busy ? 'disabled' : ''}>Revalidar</button>`}
1026
+ </div>
1027
+ </div>
1028
+ </header>
1029
+ `;
1030
+ }
1031
+
1032
+ function renderSkeletonView() {
1033
+ return `
1034
+ <section class="stack">
1035
+ <div class="metrics-grid">
1036
+ ${Array.from({ length: 4 })
1037
+ .map(
1038
+ () => `
1039
+ <article class="metric-card skeleton">
1040
+ <div class="skeleton-line w-30"></div>
1041
+ <div class="skeleton-line w-60"></div>
1042
+ <div class="skeleton-line w-80"></div>
1043
+ </article>
1044
+ `,
1045
+ )
1046
+ .join('')}
1047
+ </div>
1048
+ <section class="panel skeleton">
1049
+ <div class="skeleton-line w-50"></div>
1050
+ <div class="skeleton-line w-90"></div>
1051
+ <div class="skeleton-line w-85"></div>
1052
+ </section>
1053
+ </section>
1054
+ `;
1055
+ }
1056
+
1057
+ function renderUnlockView() {
1058
+ const adminStatus = state.adminStatus || {};
1059
+ const google = adminStatus.google || {};
1060
+ const googleSession = google?.user ? google : { authenticated: false };
1061
+ const localGoogleCache = readLocalGoogleAuthCache();
1062
+ const hasLocalCache = Boolean(localGoogleCache?.user?.sub);
1063
+ const googleConfigEnabled = Boolean(state.googleAuthConfig?.enabled && state.googleAuthConfig?.clientId);
1064
+
1065
+ return `
1066
+ <section class="stack">
1067
+ <section class="panel">
1068
+ <div class="panel-head">
1069
+ <div>
1070
+ <h2 class="panel-title">Controle total do marketplace</h2>
1071
+ <p class="panel-desc">Autenticacao dupla: conta Google (owner ou moderador autorizado) + senha do painel.</p>
1072
+ </div>
1073
+ <a class="outline-btn" href="${escapeHtml(webPath)}">Voltar ao catalogo</a>
1074
+ </div>
1075
+
1076
+ <div class="section-grid two-col">
1077
+ <article class="panel inner">
1078
+ <div class="panel-head slim">
1079
+ <h3 class="panel-title">Login Google do site</h3>
1080
+ <button class="subtle-btn" data-action="refresh-admin-status" ${state.busy ? 'disabled' : ''}>Revalidar</button>
1081
+ </div>
1082
+
1083
+ ${
1084
+ googleSession?.authenticated
1085
+ ? `
1086
+ <div class="account-box">
1087
+ <p class="row-title">${escapeHtml(googleSession.user?.name || 'Conta Google')}</p>
1088
+ <p class="row-sub break-all">${escapeHtml(googleSession.user?.email || '')}</p>
1089
+ <p class="row-meta">${canUnlockAdmin() ? 'Conta elegivel para admin.' : 'Conta Google logada nao esta elegivel para admin.'}</p>
1090
+ </div>
1091
+ `
1092
+ : `
1093
+ <div class="account-box warning">
1094
+ <p class="row-title">Nenhuma sessao Google ativa no servidor.</p>
1095
+ ${hasLocalCache ? `<p class="row-sub">Sessao local encontrada: ${escapeHtml(localGoogleCache.user?.email || localGoogleCache.user?.name || 'Conta Google')}.</p>` : '<p class="row-sub">Nao encontramos cache local de login Google.</p>'}
1096
+ <p class="row-meta">Renove o login Google abaixo para continuar.</p>
1097
+ </div>
1098
+ ${
1099
+ googleConfigEnabled
1100
+ ? `
1101
+ <div class="google-login-box">
1102
+ <div data-google-admin-login-button class="google-login-slot"></div>
1103
+ ${state.googleLoginUiReady ? '' : '<p class="row-meta">Carregando botao de login Google...</p>'}
1104
+ </div>
1105
+ `
1106
+ : `<p class="row-meta">Login Google indisponivel no painel (${escapeHtml(state.googleAuthConfigError || 'config nao encontrada')}).</p>`
1107
+ }
1108
+ `
1109
+ }
1110
+ </article>
1111
+
1112
+ <article class="panel inner">
1113
+ <div class="panel-head slim">
1114
+ <h3 class="panel-title">Senha do painel</h3>
1115
+ </div>
1116
+ <form data-form="admin-unlock" class="form-grid">
1117
+ <input class="search-input" type="password" name="password" placeholder="Digite a senha do painel" autocomplete="current-password" />
1118
+ <button class="primary-btn" type="submit" ${state.busy ? 'disabled' : ''}>${state.busy ? 'Validando...' : 'Desbloquear Painel'}</button>
1119
+ ${!canUnlockAdmin() ? '<p class="hint warning">A senha so desbloqueia apos sessao Google elegivel (owner ou moderador autorizado).</p>' : '<p class="hint">Sessao Google elegivel detectada. Informe a senha correspondente.</p>'}
1120
+ </form>
1121
+ </article>
1122
+ </div>
1123
+ </section>
1124
+ </section>
1125
+ `;
1126
+ }
1127
+
1128
+ function renderToast() {
1129
+ if (!state.toast?.message) return '';
1130
+ const tone = state.toast.type === 'error' ? 'toast danger' : 'toast success';
1131
+ return `<div class="toast-stack"><div class="${tone}">${escapeHtml(state.toast.message)}</div></div>`;
1132
+ }
1133
+
1134
+ function renderMainContent() {
1135
+ if (state.loading) return renderSkeletonView();
1136
+ if (!isAdminAuthenticated()) return renderUnlockView();
1137
+
1138
+ return `
1139
+ <section class="stack">
1140
+ ${renderMetricCards()}
1141
+ ${renderTabs()}
1142
+ ${renderActiveTabContent()}
1143
+ </section>
1144
+ `;
1145
+ }
1146
+
1147
+ function renderLayout() {
1148
+ root.innerHTML = `
1149
+ <div class="admin-app">
1150
+ ${renderHeader()}
1151
+ <div class="layout-wrap">
1152
+ ${renderSidebar()}
1153
+ <main class="main">
1154
+ ${state.error ? `<div class="inline-alert">${escapeHtml(state.error)}</div>` : ''}
1155
+ ${renderMainContent()}
1156
+ </main>
1157
+ </div>
1158
+ ${renderSidebar({ mobile: true })}
1159
+ ${renderToast()}
1160
+ </div>
1161
+ `;
1162
+ }
1163
+
1164
+ function render() {
1165
+ renderLayout();
1166
+ void ensureGoogleLoginButtonRendered();
1167
+ }
1168
+
1169
+ async function loadAdminStatus() {
1170
+ const payload = await fetchJson(`${adminApiBase}/session`);
1171
+ state.adminStatus = payload?.data || null;
1172
+ }
1173
+
1174
+ async function loadGoogleAuthConfig() {
1175
+ try {
1176
+ const payload = await fetchJson(createConfigApiPath);
1177
+ const google = payload?.data?.auth?.google || {};
1178
+ state.googleAuthConfig = {
1179
+ enabled: Boolean(google?.enabled),
1180
+ clientId: String(google?.client_id || '').trim(),
1181
+ };
1182
+ state.googleAuthConfigError = '';
1183
+ } catch {
1184
+ state.googleAuthConfig = { enabled: false, clientId: '' };
1185
+ state.googleAuthConfigError = 'Falha ao carregar configuracao Google.';
1186
+ }
1187
+ }
1188
+
1189
+ async function loadOverview() {
1190
+ const payload = await fetchJson(`${adminApiBase}/overview`);
1191
+ state.overview = payload?.data || null;
1192
+ }
1193
+
1194
+ async function loadPacks(query = state.packsQuery) {
1195
+ state.packsQuery = String(query || '').trim();
1196
+ const params = new URLSearchParams();
1197
+ params.set('limit', '120');
1198
+ if (state.packsQuery) params.set('q', state.packsQuery);
1199
+ const payload = await fetchJson(`${adminApiBase}/packs?${params.toString()}`);
1200
+ state.packs = Array.isArray(payload?.data) ? payload.data : [];
1201
+ }
1202
+
1203
+ async function loadModerators() {
1204
+ if (!canManageModerators()) {
1205
+ state.moderators = [];
1206
+ return;
1207
+ }
1208
+ const payload = await fetchJson(`${adminApiBase}/moderators`);
1209
+ state.moderators = Array.isArray(payload?.data?.moderators) ? payload.data.moderators : [];
1210
+ }
1211
+
1212
+ async function loadSelectedPack(packKey) {
1213
+ const normalized = String(packKey || '').trim();
1214
+ if (!normalized) {
1215
+ state.selectedPackKey = '';
1216
+ state.selectedPack = null;
1217
+ return;
1218
+ }
1219
+ state.selectedPackKey = normalized;
1220
+ const payload = await fetchJson(`${adminApiBase}/packs/${encodeURIComponent(normalized)}`);
1221
+ state.selectedPack = payload?.data || null;
1222
+ }
1223
+
1224
+ async function bootstrapDashboardData() {
1225
+ const jobs = [loadOverview(), loadPacks(state.packsQuery || '')];
1226
+ if (canManageModerators()) jobs.push(loadModerators());
1227
+ else state.moderators = [];
1228
+ await Promise.all(jobs);
1229
+ if (state.selectedPackKey) {
1230
+ await loadSelectedPack(state.selectedPackKey).catch(() => {
1231
+ state.selectedPack = null;
1232
+ state.selectedPackKey = '';
1233
+ });
1234
+ }
1235
+ }
1236
+
1237
+ async function runTask(task, { successMessage = '', keepError = false } = {}) {
1238
+ if (state.busy) return;
1239
+ setBusy(true);
1240
+ if (!keepError) clearError();
1241
+ render();
1242
+ try {
1243
+ await task();
1244
+ if (successMessage) setToast('success', successMessage);
1245
+ } catch (error) {
1246
+ setError(error?.message || 'Falha ao executar operacao.');
1247
+ } finally {
1248
+ setBusy(false);
1249
+ render();
1250
+ }
1251
+ }
1252
+
1253
+ async function boot() {
1254
+ state.loading = true;
1255
+ clearError();
1256
+ render();
1257
+ try {
1258
+ await Promise.all([loadAdminStatus(), loadGoogleAuthConfig()]);
1259
+ if (isAdminAuthenticated()) {
1260
+ await bootstrapDashboardData();
1261
+ }
1262
+ } catch (error) {
1263
+ setError(error?.message || 'Falha ao carregar painel admin.');
1264
+ } finally {
1265
+ state.loading = false;
1266
+ render();
1267
+ }
1268
+ }
1269
+
1270
+ async function refreshAdminStatusOnly() {
1271
+ await runTask(
1272
+ async () => {
1273
+ await Promise.all([loadAdminStatus(), loadGoogleAuthConfig()]);
1274
+ },
1275
+ { successMessage: 'Status de autenticacao atualizado.' },
1276
+ );
1277
+ }
1278
+
1279
+ async function refreshDashboard() {
1280
+ await runTask(
1281
+ async () => {
1282
+ await Promise.all([loadAdminStatus(), loadGoogleAuthConfig()]);
1283
+ if (!isAdminAuthenticated()) {
1284
+ state.overview = null;
1285
+ state.packs = [];
1286
+ state.moderators = [];
1287
+ state.selectedPack = null;
1288
+ state.selectedPackKey = '';
1289
+ return;
1290
+ }
1291
+ await bootstrapDashboardData();
1292
+ },
1293
+ { successMessage: 'Painel atualizado.' },
1294
+ );
1295
+ }
1296
+
1297
+ async function loginGoogleForAdmin(credential) {
1298
+ await runTask(
1299
+ async () => {
1300
+ const payload = await fetchJson(googleSessionApiPath, {
1301
+ method: 'POST',
1302
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1303
+ body: JSON.stringify({ google_id_token: credential }),
1304
+ });
1305
+ const data = payload?.data || {};
1306
+ if (!data?.authenticated || !data?.user?.sub) {
1307
+ throw new Error('Nao foi possivel criar sessao Google do site.');
1308
+ }
1309
+ await loadAdminStatus();
1310
+ },
1311
+ { successMessage: 'Sessao Google renovada.' },
1312
+ );
1313
+ }
1314
+
1315
+ async function unlockAdmin(password) {
1316
+ await runTask(
1317
+ async () => {
1318
+ if (!canUnlockAdmin()) {
1319
+ const google = state.adminStatus?.google || {};
1320
+ if (!google?.authenticated) {
1321
+ const local = readLocalGoogleAuthCache();
1322
+ if (local?.user?.email) {
1323
+ throw new Error(`Sua sessao Google do servidor expirou. Renove o login Google (${local.user.email}) e tente novamente.`);
1324
+ }
1325
+ throw new Error('Faca login Google no site com o email admin antes de digitar a senha.');
1326
+ }
1327
+ throw new Error('Conta Google atual nao esta elegivel para desbloquear o painel.');
1328
+ }
1329
+
1330
+ const payload = await fetchJson(`${adminApiBase}/session`, {
1331
+ method: 'POST',
1332
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1333
+ body: JSON.stringify({ password }),
1334
+ });
1335
+ state.adminStatus = payload?.data || null;
1336
+ state.activeTab = 'users';
1337
+ await bootstrapDashboardData();
1338
+ },
1339
+ { successMessage: 'Painel admin desbloqueado.' },
1340
+ );
1341
+ }
1342
+
1343
+ async function logoutAdmin() {
1344
+ await runTask(
1345
+ async () => {
1346
+ await fetchJson(`${adminApiBase}/session`, { method: 'DELETE' });
1347
+ await loadAdminStatus();
1348
+ state.overview = null;
1349
+ state.packs = [];
1350
+ state.moderators = [];
1351
+ state.selectedPack = null;
1352
+ state.selectedPackKey = '';
1353
+ },
1354
+ { successMessage: 'Sessao admin encerrada.' },
1355
+ );
1356
+ }
1357
+
1358
+ async function searchPacks(query) {
1359
+ await runTask(async () => {
1360
+ state.pagination.packs = 1;
1361
+ await loadPacks(query);
1362
+ });
1363
+ }
1364
+
1365
+ async function openPackDetailsAdmin(packKey) {
1366
+ await runTask(async () => {
1367
+ state.activeTab = 'packs';
1368
+ await loadSelectedPack(packKey);
1369
+ });
1370
+ }
1371
+
1372
+ async function deletePackAdmin(packKey) {
1373
+ if (!window.confirm(`Apagar pack "${packKey}"?`)) return;
1374
+ await runTask(
1375
+ async () => {
1376
+ await fetchJson(`${adminApiBase}/packs/${encodeURIComponent(packKey)}/delete`, {
1377
+ method: 'DELETE',
1378
+ });
1379
+ await loadPacks(state.packsQuery || '');
1380
+ if (state.selectedPackKey === packKey) {
1381
+ state.selectedPack = null;
1382
+ state.selectedPackKey = '';
1383
+ }
1384
+ if (state.overview) await loadOverview();
1385
+ },
1386
+ { successMessage: 'Pack removido.' },
1387
+ );
1388
+ }
1389
+
1390
+ async function removeStickerFromPackAdmin(packKey, stickerId) {
1391
+ if (!window.confirm(`Remover sticker ${stickerId} do pack ${packKey}?`)) return;
1392
+ await runTask(
1393
+ async () => {
1394
+ await fetchJson(`${adminApiBase}/packs/${encodeURIComponent(packKey)}/stickers/${encodeURIComponent(stickerId)}/delete`, {
1395
+ method: 'DELETE',
1396
+ });
1397
+ await loadSelectedPack(packKey);
1398
+ await loadPacks(state.packsQuery || '');
1399
+ if (state.overview) await loadOverview();
1400
+ },
1401
+ { successMessage: 'Sticker removido do pack.' },
1402
+ );
1403
+ }
1404
+
1405
+ async function forceDeleteStickerAdmin(stickerId) {
1406
+ if (!window.confirm(`Apagar sticker ${stickerId} globalmente (todas as referencias)?`)) return;
1407
+ await runTask(
1408
+ async () => {
1409
+ await fetchJson(`${adminApiBase}/stickers/${encodeURIComponent(stickerId)}/delete`, {
1410
+ method: 'DELETE',
1411
+ });
1412
+ if (state.selectedPackKey) {
1413
+ await loadSelectedPack(state.selectedPackKey).catch(() => {
1414
+ state.selectedPack = null;
1415
+ });
1416
+ }
1417
+ await loadPacks(state.packsQuery || '');
1418
+ if (state.overview) await loadOverview();
1419
+ },
1420
+ { successMessage: 'Sticker removido globalmente.' },
1421
+ );
1422
+ }
1423
+
1424
+ async function createBanAdmin(payload) {
1425
+ await runTask(
1426
+ async () => {
1427
+ await fetchJson(`${adminApiBase}/bans`, {
1428
+ method: 'POST',
1429
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1430
+ body: JSON.stringify(payload),
1431
+ });
1432
+ await loadOverview();
1433
+ },
1434
+ { successMessage: 'Usuario banido.' },
1435
+ );
1436
+ }
1437
+
1438
+ async function revokeBanAdmin(banId) {
1439
+ if (!window.confirm('Revogar este banimento?')) return;
1440
+ await runTask(
1441
+ async () => {
1442
+ await fetchJson(`${adminApiBase}/bans/${encodeURIComponent(banId)}/revoke`, {
1443
+ method: 'DELETE',
1444
+ });
1445
+ await loadOverview();
1446
+ },
1447
+ { successMessage: 'Banimento revogado.' },
1448
+ );
1449
+ }
1450
+
1451
+ async function upsertModeratorAdmin(payload) {
1452
+ await runTask(
1453
+ async () => {
1454
+ await fetchJson(`${adminApiBase}/moderators`, {
1455
+ method: 'POST',
1456
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1457
+ body: JSON.stringify(payload),
1458
+ });
1459
+ await Promise.all([loadModerators(), loadOverview()]);
1460
+ },
1461
+ { successMessage: 'Moderador salvo com sucesso.' },
1462
+ );
1463
+ }
1464
+
1465
+ async function revokeModeratorAdmin(googleSub) {
1466
+ const normalized = String(googleSub || '').trim();
1467
+ if (!normalized) return;
1468
+ if (!window.confirm('Remover acesso deste moderador?')) return;
1469
+ await runTask(
1470
+ async () => {
1471
+ await fetchJson(`${adminApiBase}/moderators/${encodeURIComponent(normalized)}`, {
1472
+ method: 'DELETE',
1473
+ });
1474
+ await Promise.all([loadModerators(), loadOverview()]);
1475
+ },
1476
+ { successMessage: 'Moderador removido.' },
1477
+ );
1478
+ }
1479
+
1480
+ async function ensureGoogleLoginButtonRendered() {
1481
+ if (state.loading || isAdminAuthenticated()) return;
1482
+ const googleSession = state.adminStatus?.google || {};
1483
+ if (googleSession?.authenticated) return;
1484
+ const clientId = String(state.googleAuthConfig?.clientId || '').trim();
1485
+ if (!clientId || !state.googleAuthConfig?.enabled) return;
1486
+
1487
+ const mount = root.querySelector('[data-google-admin-login-button]');
1488
+ if (!(mount instanceof HTMLElement)) return;
1489
+
1490
+ const renderNonce = ++googleLoginRenderNonce;
1491
+ state.googleLoginUiReady = false;
1492
+
1493
+ try {
1494
+ await loadScript(GOOGLE_GSI_SCRIPT_SRC);
1495
+ if (renderNonce !== googleLoginRenderNonce) return;
1496
+
1497
+ const accounts = window.google?.accounts?.id;
1498
+ if (!accounts) throw new Error('SDK do Google indisponivel no navegador.');
1499
+
1500
+ accounts.initialize({
1501
+ client_id: clientId,
1502
+ callback: async (response) => {
1503
+ const credential = String(response?.credential || '').trim();
1504
+ const claims = decodeJwtPayload(credential);
1505
+ if (!credential || !claims?.sub) {
1506
+ setError('Falha ao concluir login Google.');
1507
+ render();
1508
+ return;
1509
+ }
1510
+ await loginGoogleForAdmin(credential);
1511
+ render();
1512
+ },
1513
+ auto_select: false,
1514
+ cancel_on_tap_outside: true,
1515
+ });
1516
+
1517
+ mount.innerHTML = '';
1518
+ const measuredWidth = Math.floor(Number(mount.clientWidth || 0));
1519
+ const buttonWidth = Math.max(220, Math.min(360, measuredWidth || 320));
1520
+ accounts.renderButton(mount, {
1521
+ type: 'standard',
1522
+ theme: 'filled_black',
1523
+ size: 'large',
1524
+ text: 'signin_with',
1525
+ shape: 'pill',
1526
+ logo_alignment: 'left',
1527
+ width: buttonWidth,
1528
+ });
1529
+ state.googleLoginUiReady = true;
1530
+ } catch (error) {
1531
+ if (renderNonce !== googleLoginRenderNonce) return;
1532
+ state.googleLoginUiReady = false;
1533
+ setError(error?.message || 'Falha ao carregar login Google no painel admin.');
1534
+ }
1535
+ }
1536
+
1537
+ root.addEventListener('submit', async (event) => {
1538
+ const form = event.target;
1539
+ if (!(form instanceof HTMLFormElement)) return;
1540
+ const formType = form.dataset.form;
1541
+ if (!formType) return;
1542
+ event.preventDefault();
1543
+
1544
+ if (formType === 'admin-unlock') {
1545
+ const password = String(new FormData(form).get('password') || '').trim();
1546
+ if (!password) {
1547
+ setError('Informe a senha do painel admin.');
1548
+ render();
1549
+ return;
1550
+ }
1551
+ await unlockAdmin(password);
1552
+ return;
1553
+ }
1554
+
1555
+ if (formType === 'users-search') {
1556
+ state.usersQuery = String(new FormData(form).get('q') || '').trim();
1557
+ state.pagination.sessions = 1;
1558
+ state.pagination.users = 1;
1559
+ render();
1560
+ return;
1561
+ }
1562
+
1563
+ if (formType === 'packs-search') {
1564
+ const q = String(new FormData(form).get('q') || '').trim();
1565
+ await searchPacks(q);
1566
+ return;
1567
+ }
1568
+
1569
+ if (formType === 'logs-search') {
1570
+ state.logsQuery = String(new FormData(form).get('q') || '').trim();
1571
+ state.pagination.bans = 1;
1572
+ render();
1573
+ return;
1574
+ }
1575
+
1576
+ if (formType === 'moderator-upsert') {
1577
+ const fd = new FormData(form);
1578
+ const target = String(fd.get('target') || '').trim();
1579
+ const password = String(fd.get('password') || '').trim();
1580
+ if (!target) {
1581
+ setError('Informe email, google_sub ou owner_jid do moderador.');
1582
+ render();
1583
+ return;
1584
+ }
1585
+ if (password.length < 8) {
1586
+ setError('A senha do moderador deve ter no minimo 8 caracteres.');
1587
+ render();
1588
+ return;
1589
+ }
1590
+ const payload = { password };
1591
+ if (target.includes('@') && !target.endsWith('@s.whatsapp.net')) payload.email = target;
1592
+ else if (target.endsWith('@s.whatsapp.net')) payload.owner_jid = target;
1593
+ else payload.google_sub = target;
1594
+ await upsertModeratorAdmin(payload);
1595
+ form.reset();
1596
+ return;
1597
+ }
1598
+
1599
+ if (formType === 'manual-ban') {
1600
+ const fd = new FormData(form);
1601
+ const payload = {
1602
+ email: String(fd.get('email') || '').trim(),
1603
+ google_sub: String(fd.get('google_sub') || '').trim(),
1604
+ owner_jid: String(fd.get('owner_jid') || '').trim(),
1605
+ reason: String(fd.get('reason') || '').trim(),
1606
+ };
1607
+ if (!payload.email && !payload.google_sub && !payload.owner_jid) {
1608
+ setError('Informe email, google_sub ou owner_jid para banir.');
1609
+ render();
1610
+ return;
1611
+ }
1612
+ await createBanAdmin(payload);
1613
+ form.reset();
1614
+ return;
1615
+ }
1616
+
1617
+ if (formType === 'delete-sticker-global') {
1618
+ const stickerId = String(new FormData(form).get('sticker_id') || '').trim();
1619
+ if (!stickerId) {
1620
+ setError('Informe o sticker_id para apagar.');
1621
+ render();
1622
+ return;
1623
+ }
1624
+ await forceDeleteStickerAdmin(stickerId);
1625
+ }
1626
+ });
1627
+
1628
+ root.addEventListener('click', async (event) => {
1629
+ const target = event.target instanceof HTMLElement ? event.target : null;
1630
+ if (!target) return;
1631
+
1632
+ const actionEl = target.closest('[data-action]');
1633
+ const clickedInsideMenu = Boolean(target.closest('[data-row-menu]'));
1634
+
1635
+ if (!clickedInsideMenu && (!actionEl || actionEl.dataset.action !== 'toggle-row-menu') && state.rowMenu) {
1636
+ state.rowMenu = null;
1637
+ render();
1638
+ return;
1639
+ }
1640
+
1641
+ if (!(actionEl instanceof HTMLElement)) return;
1642
+
1643
+ const action = actionEl.dataset.action;
1644
+ if (!action) return;
1645
+
1646
+ if (action === 'toggle-sidebar') {
1647
+ state.sidebarOpen = !state.sidebarOpen;
1648
+ render();
1649
+ return;
1650
+ }
1651
+
1652
+ if (action === 'close-sidebar') {
1653
+ state.sidebarOpen = false;
1654
+ render();
1655
+ return;
1656
+ }
1657
+
1658
+ if (action === 'switch-tab') {
1659
+ const tabId = String(actionEl.dataset.tabId || '').trim();
1660
+ if (TAB_ITEMS.some((tab) => tab.id === tabId)) {
1661
+ state.activeTab = tabId;
1662
+ state.sidebarOpen = false;
1663
+ state.rowMenu = null;
1664
+ render();
1665
+ }
1666
+ return;
1667
+ }
1668
+
1669
+ if (action === 'refresh-dashboard') {
1670
+ await refreshDashboard();
1671
+ return;
1672
+ }
1673
+
1674
+ if (action === 'refresh-admin-status') {
1675
+ await refreshAdminStatusOnly();
1676
+ return;
1677
+ }
1678
+
1679
+ if (action === 'logout-admin') {
1680
+ await logoutAdmin();
1681
+ return;
1682
+ }
1683
+
1684
+ if (action === 'toggle-row-menu') {
1685
+ const kind = String(actionEl.dataset.rowKind || '').trim();
1686
+ const id = String(actionEl.dataset.rowId || '').trim();
1687
+ if (!kind || !id) return;
1688
+ if (isRowMenuOpen(kind, id)) {
1689
+ state.rowMenu = null;
1690
+ } else {
1691
+ state.rowMenu = { kind, id };
1692
+ }
1693
+ render();
1694
+ return;
1695
+ }
1696
+
1697
+ if (action === 'page-nav') {
1698
+ const targetKey = String(actionEl.dataset.pageTarget || '').trim();
1699
+ const nextPage = Number(actionEl.dataset.page || 1);
1700
+ if (!targetKey || !Number.isFinite(nextPage)) return;
1701
+ state.pagination[targetKey] = Math.max(1, Math.floor(nextPage));
1702
+ render();
1703
+ return;
1704
+ }
1705
+
1706
+ if (action === 'open-pack-admin') {
1707
+ state.rowMenu = null;
1708
+ await openPackDetailsAdmin(actionEl.dataset.packKey || '');
1709
+ return;
1710
+ }
1711
+
1712
+ if (action === 'delete-pack-admin') {
1713
+ state.rowMenu = null;
1714
+ await deletePackAdmin(actionEl.dataset.packKey || '');
1715
+ return;
1716
+ }
1717
+
1718
+ if (action === 'remove-pack-sticker') {
1719
+ state.rowMenu = null;
1720
+ await removeStickerFromPackAdmin(actionEl.dataset.packKey || '', actionEl.dataset.stickerId || '');
1721
+ return;
1722
+ }
1723
+
1724
+ if (action === 'delete-sticker-global-btn') {
1725
+ state.rowMenu = null;
1726
+ await forceDeleteStickerAdmin(actionEl.dataset.stickerId || '');
1727
+ return;
1728
+ }
1729
+
1730
+ if (action === 'ban-user') {
1731
+ state.rowMenu = null;
1732
+ const reason = window.prompt('Motivo do banimento (opcional):', 'Violacao das regras do marketplace') || '';
1733
+ await createBanAdmin({
1734
+ email: actionEl.dataset.email || '',
1735
+ google_sub: actionEl.dataset.sub || '',
1736
+ owner_jid: actionEl.dataset.owner || '',
1737
+ reason,
1738
+ });
1739
+ return;
1740
+ }
1741
+
1742
+ if (action === 'revoke-ban') {
1743
+ state.rowMenu = null;
1744
+ await revokeBanAdmin(actionEl.dataset.banId || '');
1745
+ return;
1746
+ }
1747
+
1748
+ if (action === 'revoke-moderator') {
1749
+ await revokeModeratorAdmin(actionEl.dataset.googleSub || '');
1750
+ }
1751
+ });
1752
+
1753
+ boot();