@omnizap-system/omnizap 2.6.1 → 2.6.2

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 (156) hide show
  1. package/.env.example +54 -9
  2. package/.github/workflows/ci.yml +3 -3
  3. package/.github/workflows/security-runner-hardening.yml +1 -1
  4. package/.github/workflows/security-zap-full-scan.yml +1 -0
  5. package/app/config/index.js +2 -0
  6. package/app/configParts/adminIdentity.js +5 -5
  7. package/app/configParts/baileysConfig.js +226 -55
  8. package/app/configParts/groupUtils.js +5 -0
  9. package/app/configParts/messagePersistenceService.js +143 -3
  10. package/app/configParts/sessionConfig.js +157 -0
  11. package/app/connection/baileysCompatibility.test.js +1 -1
  12. package/app/connection/groupOwnerWriteStateResolver.js +109 -0
  13. package/app/connection/socketController.js +625 -124
  14. package/app/connection/socketController.multiSession.test.js +108 -0
  15. package/app/controllers/messageController.js +1 -1
  16. package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
  17. package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
  18. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
  19. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +80 -2
  20. package/app/controllers/messageProcessingPipeline.js +88 -9
  21. package/app/controllers/messageProcessingPipeline.test.js +200 -0
  22. package/app/modules/adminModule/AGENT.md +1 -1
  23. package/app/modules/adminModule/commandConfig.json +3318 -1347
  24. package/app/modules/adminModule/groupCommandHandlers.js +856 -14
  25. package/app/modules/adminModule/groupCommandHandlers.test.js +375 -9
  26. package/app/modules/adminModule/groupWarningRepository.js +152 -0
  27. package/app/modules/aiModule/AGENT.md +47 -30
  28. package/app/modules/aiModule/aiConfigRuntime.js +1 -0
  29. package/app/modules/aiModule/catCommand.js +132 -25
  30. package/app/modules/aiModule/commandConfig.json +114 -28
  31. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
  32. package/app/modules/gameModule/AGENT.md +1 -1
  33. package/app/modules/gameModule/commandConfig.json +29 -0
  34. package/app/modules/menuModule/AGENT.md +1 -1
  35. package/app/modules/menuModule/commandConfig.json +45 -10
  36. package/app/modules/menuModule/menuCatalogService.js +190 -0
  37. package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
  38. package/app/modules/menuModule/menuDynamicService.js +511 -0
  39. package/app/modules/menuModule/menuDynamicService.test.js +141 -0
  40. package/app/modules/menuModule/menus.js +36 -5
  41. package/app/modules/playModule/AGENT.md +10 -5
  42. package/app/modules/playModule/commandConfig.json +74 -16
  43. package/app/modules/playModule/playCommandConstants.js +13 -7
  44. package/app/modules/playModule/playCommandCore.js +4 -6
  45. package/app/modules/playModule/{playCommandYtDlpClient.js → playCommandMediaClient.js} +684 -332
  46. package/app/modules/playModule/playConfigRuntime.js +5 -6
  47. package/app/modules/playModule/playModuleCriticalFlows.test.js +44 -59
  48. package/app/modules/quoteModule/AGENT.md +1 -1
  49. package/app/modules/quoteModule/commandConfig.json +29 -0
  50. package/app/modules/rpgPokemonModule/AGENT.md +1 -1
  51. package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
  52. package/app/modules/statsModule/AGENT.md +1 -1
  53. package/app/modules/statsModule/commandConfig.json +58 -0
  54. package/app/modules/stickerModule/AGENT.md +1 -1
  55. package/app/modules/stickerModule/commandConfig.json +145 -0
  56. package/app/modules/stickerPackModule/AGENT.md +1 -1
  57. package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
  58. package/app/modules/stickerPackModule/commandConfig.json +29 -0
  59. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +1 -1
  60. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +78 -57
  61. package/app/modules/stickerPackModule/stickerPackService.js +13 -6
  62. package/app/modules/systemMetricsModule/AGENT.md +1 -1
  63. package/app/modules/systemMetricsModule/commandConfig.json +29 -0
  64. package/app/modules/tiktokModule/AGENT.md +1 -1
  65. package/app/modules/tiktokModule/commandConfig.json +29 -0
  66. package/app/modules/userModule/AGENT.md +1 -1
  67. package/app/modules/userModule/commandConfig.json +29 -0
  68. package/app/modules/waifuPicsModule/AGENT.md +57 -27
  69. package/app/modules/waifuPicsModule/commandConfig.json +87 -0
  70. package/app/observability/metrics.js +136 -0
  71. package/app/services/ai/commandConfigEnrichmentService.js +229 -47
  72. package/app/services/ai/geminiService.js +131 -7
  73. package/app/services/ai/geminiService.test.js +59 -2
  74. package/app/services/ai/moduleAiHelpCoreService.js +33 -4
  75. package/app/services/group/groupMetadataService.js +24 -1
  76. package/app/services/infra/dbWriteQueue.js +51 -21
  77. package/app/services/messaging/newsBroadcastService.js +843 -27
  78. package/app/services/multiSession/assignmentBalancerService.js +457 -0
  79. package/app/services/multiSession/groupOwnershipRepository.js +381 -0
  80. package/app/services/multiSession/groupOwnershipService.js +890 -0
  81. package/app/services/multiSession/groupOwnershipService.test.js +309 -0
  82. package/app/services/multiSession/sessionRegistryService.js +293 -0
  83. package/app/store/aiPromptStore.js +36 -19
  84. package/app/store/groupConfigStore.js +41 -5
  85. package/app/store/premiumUserStore.js +21 -7
  86. package/app/utils/antiLink/antiLinkModule.js +352 -16
  87. package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
  88. package/database/index.js +6 -0
  89. package/database/migrations/20260307_d0_hardening_down.sql +1 -1
  90. package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
  91. package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
  92. package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
  93. package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
  94. package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
  95. package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
  96. package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
  97. package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
  98. package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
  99. package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
  100. package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
  101. package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
  102. package/database/schema.sql +102 -1
  103. package/docker-compose.yml +4 -1
  104. package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
  105. package/docs/compliance/privacy-policy-2026-03-07.md +2 -2
  106. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
  107. package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
  108. package/docs/security/omnizap-static-security-headers.conf +25 -0
  109. package/ecosystem.prod.config.cjs +31 -11
  110. package/index.js +52 -18
  111. package/observability/alert-rules.yml +20 -0
  112. package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
  113. package/observability/mysql-setup.sql +4 -4
  114. package/observability/system-admin-observability.md +26 -0
  115. package/package.json +12 -5
  116. package/public/comandos/commands-catalog.json +2253 -78
  117. package/public/js/apps/commandsReactApp.js +267 -87
  118. package/public/js/apps/createPackApp.js +3 -3
  119. package/public/js/apps/stickersApp.js +255 -103
  120. package/public/js/apps/termsReactApp.js +57 -8
  121. package/public/js/apps/userPasswordResetReactApp.js +406 -0
  122. package/public/js/apps/userReactApp.js +96 -47
  123. package/public/js/apps/userSystemAdmReactApp.js +1506 -0
  124. package/public/pages/politica-de-privacidade.html +1 -1
  125. package/public/pages/stickers.html +5 -5
  126. package/public/pages/termos-de-uso-texto-integral.html +1 -1
  127. package/public/pages/termos-de-uso.html +1 -1
  128. package/public/pages/user-password-reset.html +3 -4
  129. package/public/pages/user-systemadm.html +8 -462
  130. package/public/pages/user.html +1 -1
  131. package/scripts/clear-whatsapp-session.sh +123 -0
  132. package/scripts/core-ai-mode.mjs +163 -0
  133. package/scripts/deploy.sh +10 -0
  134. package/scripts/enrich-command-config-ux-openai.mjs +492 -0
  135. package/scripts/generate-commands-catalog.mjs +155 -0
  136. package/scripts/new-whatsapp-session.sh +317 -0
  137. package/scripts/security-web-surface-check.mjs +218 -0
  138. package/server/controllers/admin/adminPanelHandlers.js +253 -3
  139. package/server/controllers/admin/systemAdminController.js +267 -0
  140. package/server/controllers/sticker/stickerCatalogController.js +9 -23
  141. package/server/controllers/system/contactController.js +9 -17
  142. package/server/controllers/system/stickerCatalogSystemContext.js +27 -6
  143. package/server/controllers/system/systemController.js +254 -1
  144. package/server/controllers/userController.js +6 -0
  145. package/server/email/emailTemplateService.js +3 -2
  146. package/server/http/httpServer.js +8 -4
  147. package/server/middleware/securityHeaders.js +20 -1
  148. package/server/routes/admin/systemAdminRouter.js +6 -0
  149. package/server/routes/indexRouter.js +30 -6
  150. package/server/routes/observability/grafanaProxyRouter.js +254 -0
  151. package/server/routes/static/staticPageRouter.js +27 -1
  152. package/server/utils/publicContact.js +31 -0
  153. package/utils/whatsapp/contactEnv.js +39 -0
  154. package/vite.config.mjs +2 -1
  155. package/app/modules/playModule/local/installYtDlp.js +0 -25
  156. package/app/modules/playModule/local/ytDlpInstaller.js +0 -28
@@ -0,0 +1,1506 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import htm from 'htm';
4
+
5
+ const html = htm.bind(React.createElement);
6
+
7
+ const DEFAULT_API_BASE_PATH = '/api';
8
+ const DEFAULT_LOGIN_PATH = '/login';
9
+ const DEFAULT_STICKERS_PATH = '/stickers';
10
+ const FALLBACK_AVATAR = 'https://iili.io/FC3FABe.jpg';
11
+
12
+ const NAV_ITEMS = Object.freeze([
13
+ { id: 'overview', label: 'Dashboard', kbd: '1' },
14
+ { id: 'moderacao', label: 'Moderação', kbd: '2' },
15
+ { id: 'usuarios', label: 'Usuários', kbd: '3' },
16
+ { id: 'sessoes', label: 'Sessões', kbd: '4' },
17
+ { id: 'saude', label: 'Saúde', kbd: '5' },
18
+ { id: 'auditoria', label: 'Auditoria', kbd: '6' },
19
+ { id: 'alertas', label: 'Alertas', kbd: '7' },
20
+ { id: 'exportacao', label: 'Exportação', kbd: '8' },
21
+ { id: 'configuracoes', label: 'Configurações', kbd: '9' },
22
+ ]);
23
+
24
+ const CRITICAL_OPS = new Set(['restart_worker', 'clear_cache']);
25
+
26
+ const normalizeString = (value) => String(value || '').trim();
27
+
28
+ const normalizeBasePath = (value, fallback) => {
29
+ const raw = normalizeString(value);
30
+ if (!raw) return fallback;
31
+ if (!raw.startsWith('/')) return fallback;
32
+ if (/^\/\//.test(raw)) return fallback;
33
+ return raw;
34
+ };
35
+
36
+ const normalizeSeverity = (value, fallback = 'low') => {
37
+ const normalized = normalizeString(value).toLowerCase();
38
+ if (['critical', 'high', 'medium', 'low'].includes(normalized)) return normalized;
39
+ if (normalized === 'error') return 'high';
40
+ if (normalized === 'warn' || normalized === 'warning') return 'medium';
41
+ return fallback;
42
+ };
43
+
44
+ const normalizeStatusTone = (value) => {
45
+ const normalized = normalizeString(value).toLowerCase();
46
+ if (normalized === 'incident') return 'incident';
47
+ if (normalized === 'warning') return 'warning';
48
+ return 'online';
49
+ };
50
+
51
+ const clampInt = (value, fallback, min, max) => {
52
+ const numeric = Number(value);
53
+ if (!Number.isFinite(numeric)) return fallback;
54
+ return Math.max(min, Math.min(max, Math.floor(numeric)));
55
+ };
56
+
57
+ const formatNumber = (value) =>
58
+ new Intl.NumberFormat('pt-BR', {
59
+ maximumFractionDigits: 0,
60
+ }).format(Math.max(0, Number(value || 0)));
61
+
62
+ const formatDateTime = (value) => {
63
+ const ms = Date.parse(String(value || ''));
64
+ if (!Number.isFinite(ms)) return 'n/d';
65
+ return new Intl.DateTimeFormat('pt-BR', {
66
+ dateStyle: 'short',
67
+ timeStyle: 'short',
68
+ }).format(new Date(ms));
69
+ };
70
+
71
+ const formatPercent = (value) => {
72
+ const numeric = Number(value);
73
+ if (!Number.isFinite(numeric)) return 'n/d';
74
+ return `${numeric.toFixed(1)}%`;
75
+ };
76
+
77
+ const formatMilliseconds = (value) => {
78
+ const numeric = Number(value);
79
+ if (!Number.isFinite(numeric)) return 'n/d';
80
+ return `${Math.round(numeric)} ms`;
81
+ };
82
+
83
+ const extractFilenameFromDisposition = (disposition, fallbackName) => {
84
+ const raw = normalizeString(disposition);
85
+ if (!raw) return fallbackName;
86
+ const utf8Match = raw.match(/filename\*=UTF-8''([^;]+)/i);
87
+ if (utf8Match?.[1]) {
88
+ return decodeURIComponent(utf8Match[1]);
89
+ }
90
+ const filenameMatch = raw.match(/filename="?([^";]+)"?/i);
91
+ if (filenameMatch?.[1]) return filenameMatch[1];
92
+ return fallbackName;
93
+ };
94
+
95
+ const triggerFileDownload = (blob, filename) => {
96
+ const url = window.URL.createObjectURL(blob);
97
+ const anchor = document.createElement('a');
98
+ anchor.href = url;
99
+ anchor.download = filename;
100
+ document.body.appendChild(anchor);
101
+ anchor.click();
102
+ anchor.remove();
103
+ window.setTimeout(() => {
104
+ window.URL.revokeObjectURL(url);
105
+ }, 350);
106
+ };
107
+
108
+ const paginate = ({ items = [], page = 1, pageSize = 6 } = {}) => {
109
+ const safeItems = Array.isArray(items) ? items : [];
110
+ if (!safeItems.length) {
111
+ return {
112
+ pageItems: [],
113
+ page: 1,
114
+ totalPages: 1,
115
+ totalItems: 0,
116
+ from: 0,
117
+ to: 0,
118
+ };
119
+ }
120
+ const safePageSize = Math.max(1, Number(pageSize || 6));
121
+ const totalPages = Math.max(1, Math.ceil(safeItems.length / safePageSize));
122
+ const safePage = Math.max(1, Math.min(totalPages, Math.floor(Number(page || 1) || 1)));
123
+ const startIndex = (safePage - 1) * safePageSize;
124
+ const endIndex = Math.min(startIndex + safePageSize, safeItems.length);
125
+ return {
126
+ pageItems: safeItems.slice(startIndex, endIndex),
127
+ page: safePage,
128
+ totalPages,
129
+ totalItems: safeItems.length,
130
+ from: startIndex + 1,
131
+ to: endIndex,
132
+ };
133
+ };
134
+
135
+ const buildIdentityPayload = ({ sessionToken = '', googleSub = '', email = '', ownerJid = '' } = {}) => {
136
+ const payload = {};
137
+ const safeSessionToken = normalizeString(sessionToken);
138
+ const safeGoogleSub = normalizeString(googleSub);
139
+ const safeEmail = normalizeString(email).toLowerCase();
140
+ const safeOwnerJid = normalizeString(ownerJid);
141
+ if (safeSessionToken) payload.session_token = safeSessionToken;
142
+ if (safeGoogleSub) payload.google_sub = safeGoogleSub;
143
+ if (safeEmail) payload.email = safeEmail;
144
+ if (safeOwnerJid) payload.owner_jid = safeOwnerJid;
145
+ return payload;
146
+ };
147
+
148
+ const buildIdentityLabel = (identity = {}) => {
149
+ if (identity.email) return identity.email;
150
+ if (identity.owner_jid) return identity.owner_jid;
151
+ if (identity.google_sub) return identity.google_sub;
152
+ if (identity.session_token) return `${identity.session_token.slice(0, 8)}...`;
153
+ return 'identidade';
154
+ };
155
+
156
+ const resolveGlobalStatus = ({ dashboardQuick = {}, systemHealth = {}, alerts = [] } = {}) => {
157
+ const list = Array.isArray(alerts) ? alerts : [];
158
+ const dbStatus = normalizeString(systemHealth?.db_status).toLowerCase();
159
+ const cpuPercent = Number(systemHealth?.cpu_percent || 0);
160
+ const errors5xx = Number(dashboardQuick?.errors_5xx || 0);
161
+
162
+ const hasCritical = list.some((item) => {
163
+ const severity = normalizeSeverity(item?.severity);
164
+ return severity === 'critical' || severity === 'high';
165
+ });
166
+ const hasWarning = list.some((item) => normalizeSeverity(item?.severity) === 'medium');
167
+
168
+ if (dbStatus === 'down' || hasCritical || errors5xx >= 30 || cpuPercent >= 92) {
169
+ return { tone: 'incident', label: 'Incident' };
170
+ }
171
+ if (dbStatus === 'degraded' || hasWarning || errors5xx >= 10 || cpuPercent >= 75) {
172
+ return { tone: 'warning', label: 'Warning' };
173
+ }
174
+ return { tone: 'online', label: 'Online' };
175
+ };
176
+
177
+ const createAdminApi = (apiBasePath) => {
178
+ const authSessionPath = `${apiBasePath}/auth/google/session`;
179
+ const adminSessionPath = `${apiBasePath}/admin/session`;
180
+ const adminOverviewPath = `${apiBasePath}/admin/overview`;
181
+ const adminSearchPath = `${apiBasePath}/admin/search`;
182
+ const adminForceLogoutPath = `${apiBasePath}/admin/users/force-logout`;
183
+ const adminBansPath = `${apiBasePath}/admin/bans`;
184
+ const adminFeatureFlagsPath = `${apiBasePath}/admin/feature-flags`;
185
+ const adminOpsPath = `${apiBasePath}/admin/ops`;
186
+ const adminExportPath = `${apiBasePath}/admin/export`;
187
+
188
+ const fetchJson = async (url, init = {}) => {
189
+ const response = await fetch(url, {
190
+ credentials: 'include',
191
+ ...init,
192
+ });
193
+
194
+ let payload = null;
195
+ try {
196
+ payload = await response.json();
197
+ } catch {
198
+ payload = null;
199
+ }
200
+
201
+ if (!response.ok) {
202
+ const error = new Error(payload?.error || `Falha HTTP ${response.status}`);
203
+ error.statusCode = response.status;
204
+ error.code = payload?.code || null;
205
+ error.details = payload?.details || null;
206
+ throw error;
207
+ }
208
+ return payload || {};
209
+ };
210
+
211
+ const fetchRaw = async (url, init = {}) => {
212
+ const response = await fetch(url, {
213
+ credentials: 'include',
214
+ ...init,
215
+ });
216
+ if (!response.ok) {
217
+ let payload = null;
218
+ try {
219
+ payload = await response.json();
220
+ } catch {
221
+ payload = null;
222
+ }
223
+ const error = new Error(payload?.error || `Falha HTTP ${response.status}`);
224
+ error.statusCode = response.status;
225
+ error.code = payload?.code || null;
226
+ throw error;
227
+ }
228
+ return response;
229
+ };
230
+
231
+ return {
232
+ getGoogleSession: () => fetchJson(authSessionPath, { method: 'GET' }),
233
+ getAdminSession: () => fetchJson(adminSessionPath, { method: 'GET' }),
234
+ unlockAdmin: (password) =>
235
+ fetchJson(adminSessionPath, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'Content-Type': 'application/json; charset=utf-8',
239
+ },
240
+ body: JSON.stringify({ password: String(password || '') }),
241
+ }),
242
+ logoutAdmin: () => fetchJson(adminSessionPath, { method: 'DELETE' }),
243
+ getOverview: () => fetchJson(adminOverviewPath, { method: 'GET' }),
244
+ search: (query, limit = 12) => fetchJson(`${adminSearchPath}?${new URLSearchParams({ q: query, limit: String(limit) }).toString()}`, { method: 'GET' }),
245
+ forceLogout: (payload) =>
246
+ fetchJson(adminForceLogoutPath, {
247
+ method: 'POST',
248
+ headers: {
249
+ 'Content-Type': 'application/json; charset=utf-8',
250
+ },
251
+ body: JSON.stringify(payload || {}),
252
+ }),
253
+ createBan: (payload) =>
254
+ fetchJson(adminBansPath, {
255
+ method: 'POST',
256
+ headers: {
257
+ 'Content-Type': 'application/json; charset=utf-8',
258
+ },
259
+ body: JSON.stringify(payload || {}),
260
+ }),
261
+ revokeBan: (banId) =>
262
+ fetchJson(`${adminBansPath}/${encodeURIComponent(String(banId || '').trim())}`, {
263
+ method: 'DELETE',
264
+ }),
265
+ upsertFeatureFlag: (payload) =>
266
+ fetchJson(adminFeatureFlagsPath, {
267
+ method: 'POST',
268
+ headers: {
269
+ 'Content-Type': 'application/json; charset=utf-8',
270
+ },
271
+ body: JSON.stringify(payload || {}),
272
+ }),
273
+ runOp: (action) =>
274
+ fetchJson(adminOpsPath, {
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/json; charset=utf-8',
278
+ },
279
+ body: JSON.stringify({ action: String(action || '').trim() }),
280
+ }),
281
+ exportDataRaw: (type, format) =>
282
+ fetchRaw(`${adminExportPath}?${new URLSearchParams({ type, format }).toString()}`, {
283
+ method: 'GET',
284
+ }),
285
+ };
286
+ };
287
+
288
+ const SeverityBadge = ({ label = '', severity = 'low' }) => html`<span className=${`admin-badge ${normalizeSeverity(severity)}`}>${label || normalizeSeverity(severity).toUpperCase()}</span>`;
289
+
290
+ const PaginationControls = ({ pagination, onPrev, onNext }) => {
291
+ if (!pagination || pagination.totalItems <= 0) return null;
292
+ return html`
293
+ <div className="list-pagination">
294
+ <p className="list-pagination-meta">Mostrando ${pagination.from}-${pagination.to} de ${pagination.totalItems}</p>
295
+ <div className="list-pagination-controls">
296
+ <button type="button" className="btn ghost" disabled=${pagination.page <= 1} onClick=${onPrev}>Anterior</button>
297
+ <span className="list-pagination-counter">${pagination.page} / ${pagination.totalPages}</span>
298
+ <button type="button" className="btn ghost" disabled=${pagination.page >= pagination.totalPages} onClick=${onNext}>Próximo</button>
299
+ </div>
300
+ </div>
301
+ `;
302
+ };
303
+
304
+ const UserSystemAdmReactApp = ({ config }) => {
305
+ const api = useMemo(() => createAdminApi(config.apiBasePath), [config.apiBasePath]);
306
+
307
+ const [activePage, setActivePage] = useState('overview');
308
+ const [envLabel, setEnvLabel] = useState('Production');
309
+
310
+ const [googleSession, setGoogleSession] = useState(null);
311
+ const [adminStatusPayload, setAdminStatusPayload] = useState(null);
312
+ const [adminOverviewPayload, setAdminOverviewPayload] = useState(null);
313
+ const [previousAdminOverviewPayload, setPreviousAdminOverviewPayload] = useState(null);
314
+
315
+ const [busy, setBusy] = useState(false);
316
+ const [unlockPassword, setUnlockPassword] = useState('');
317
+ const [adminError, setAdminError] = useState('');
318
+
319
+ const [searchQuery, setSearchQuery] = useState('');
320
+ const [searchResult, setSearchResult] = useState(null);
321
+
322
+ const [moderationSeverityFilter, setModerationSeverityFilter] = useState('all');
323
+ const [moderationTypeFilter, setModerationTypeFilter] = useState('all');
324
+ const [moderationPage, setModerationPage] = useState(1);
325
+
326
+ const [usersPage, setUsersPage] = useState(1);
327
+ const [sessionsPage, setSessionsPage] = useState(1);
328
+
329
+ const [auditStatusFilter, setAuditStatusFilter] = useState('all');
330
+ const [auditSearchQuery, setAuditSearchQuery] = useState('');
331
+ const [auditPage, setAuditPage] = useState(1);
332
+
333
+ const [alertsPage, setAlertsPage] = useState(1);
334
+
335
+ const [toasts, setToasts] = useState([]);
336
+
337
+ const pushToast = useCallback(({ kind = 'success', title = 'Status', message = '' } = {}) => {
338
+ const safeMessage = normalizeString(message);
339
+ if (!safeMessage) return;
340
+ const toastId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
341
+ setToasts((current) => [...current, { id: toastId, kind, title: normalizeString(title) || 'Status', message: safeMessage }]);
342
+ window.setTimeout(() => {
343
+ setToasts((current) => current.filter((toast) => toast.id !== toastId));
344
+ }, 3800);
345
+ }, []);
346
+
347
+ const adminSession = adminStatusPayload?.session || null;
348
+ const adminAuthenticated = Boolean(adminSession?.authenticated);
349
+ const adminEligible = Boolean(adminStatusPayload?.eligible_google_login || adminAuthenticated);
350
+
351
+ const overview = adminOverviewPayload || {};
352
+ const previousOverview = previousAdminOverviewPayload || null;
353
+ const counters = overview?.counters || {};
354
+ const dashboardQuick = overview?.dashboard_quick || {};
355
+ const systemHealth = overview?.system_health || {};
356
+ const moderationQueue = Array.isArray(overview?.moderation_queue) ? overview.moderation_queue : [];
357
+ const users = Array.isArray(overview?.users_sessions?.users) ? overview.users_sessions.users : [];
358
+ const sessions = Array.isArray(overview?.users_sessions?.active_sessions) ? overview.users_sessions.active_sessions : [];
359
+ const blockedAccounts = Array.isArray(overview?.users_sessions?.blocked_accounts) ? overview.users_sessions.blocked_accounts : [];
360
+ const auditLog = Array.isArray(overview?.audit_log) ? overview.audit_log : [];
361
+ const featureFlags = Array.isArray(overview?.feature_flags) ? overview.feature_flags : [];
362
+ const alerts = Array.isArray(overview?.alerts) ? overview.alerts : [];
363
+ const grafanaLinks = overview?.observability_links?.grafana || {};
364
+ const grafanaDashboards = Array.isArray(overview?.observability_links?.grafana?.dashboards) ? overview.observability_links.grafana.dashboards : [];
365
+ const grafanaRuntime = overview?.observability_runtime?.grafana || null;
366
+ const grafanaStatusRaw = normalizeString(grafanaRuntime?.status).toLowerCase();
367
+ const grafanaStatusLabel = grafanaStatusRaw === 'online' ? 'Online' : grafanaStatusRaw === 'degraded' ? 'Degradado' : grafanaStatusRaw === 'offline' ? 'Offline' : grafanaStatusRaw === 'unavailable' ? 'Indisponível' : 'N/D';
368
+ const grafanaStatusTrend = grafanaStatusRaw === 'online' ? 'up' : grafanaStatusRaw === 'degraded' ? 'warn' : 'down';
369
+ const grafanaStatusCardClass = grafanaStatusRaw === 'online' ? 'metric-card system' : grafanaStatusRaw === 'degraded' ? 'metric-card security warning' : 'metric-card security critical';
370
+ const grafanaVersion = normalizeString(grafanaRuntime?.version) || 'n/d';
371
+ const grafanaDatabase = normalizeString(grafanaRuntime?.database) || 'n/d';
372
+ const grafanaDashboardsTotalRaw = grafanaRuntime?.dashboards_total ?? grafanaDashboards.length;
373
+ const grafanaDashboardsTotal = Math.max(0, Number(grafanaDashboardsTotalRaw || 0));
374
+ const grafanaCheckedAtLabel = formatDateTime(grafanaRuntime?.checked_at);
375
+ const grafanaResponseLabel = Number.isFinite(Number(grafanaRuntime?.response_ms)) ? formatMilliseconds(grafanaRuntime?.response_ms) : 'n/d';
376
+ const grafanaPrimaryUrl = normalizeString(grafanaDashboards?.[0]?.view_url || grafanaLinks?.base_url);
377
+ const operationalShortcuts =
378
+ Array.isArray(overview?.operational_shortcuts) && overview.operational_shortcuts.length
379
+ ? overview.operational_shortcuts
380
+ : [
381
+ { action: 'restart_worker', label: 'Reiniciar worker', description: 'Destrava filas em processamento e recoloca em pending.' },
382
+ { action: 'clear_cache', label: 'Limpar cache', description: 'Invalida caches internos de catálogo, ranking e resumo.' },
383
+ { action: 'reprocess_jobs', label: 'Reprocessar jobs', description: 'Agenda ciclos de classificação/curadoria no worker.' },
384
+ ];
385
+
386
+ const globalStatus = useMemo(() => resolveGlobalStatus({ dashboardQuick, systemHealth, alerts }), [alerts, dashboardQuick, systemHealth]);
387
+
388
+ const profileUser = adminSession?.user || adminStatusPayload?.google?.user || googleSession?.user || null;
389
+ const profileName = normalizeString(profileUser?.name) || 'Admin';
390
+ const profilePicture = normalizeString(profileUser?.picture) || FALLBACK_AVATAR;
391
+
392
+ const deltaLabel = useCallback((current, previous, { percent = true, suffix = '' } = {}) => {
393
+ const curr = Number(current);
394
+ const prev = Number(previous);
395
+ if (!Number.isFinite(curr) || !Number.isFinite(prev)) return 'n/d';
396
+
397
+ const delta = curr - prev;
398
+ const prefix = delta > 0 ? '+' : '';
399
+ if (!percent) {
400
+ return `${prefix}${formatNumber(delta)}${suffix}`.trim();
401
+ }
402
+ if (prev === 0) {
403
+ return delta === 0 ? '0.0%' : `${prefix}100.0%`;
404
+ }
405
+ const ratio = (delta / Math.abs(prev)) * 100;
406
+ return `${ratio >= 0 ? '+' : ''}${ratio.toFixed(1)}%`;
407
+ }, []);
408
+
409
+ const navigateToPage = useCallback((targetPage) => {
410
+ const safePage = NAV_ITEMS.some((item) => item.id === targetPage) ? targetPage : 'overview';
411
+ setActivePage(safePage);
412
+ if (window.location.hash !== `#${safePage}`) {
413
+ window.history.replaceState(null, '', `#${safePage}`);
414
+ }
415
+ }, []);
416
+
417
+ useEffect(() => {
418
+ const hash = normalizeString(window.location.hash).replace(/^#/, '');
419
+ navigateToPage(hash || 'overview');
420
+ const onHashChange = () => {
421
+ const value = normalizeString(window.location.hash).replace(/^#/, '');
422
+ navigateToPage(value || 'overview');
423
+ };
424
+ window.addEventListener('hashchange', onHashChange);
425
+ return () => {
426
+ window.removeEventListener('hashchange', onHashChange);
427
+ };
428
+ }, [navigateToPage]);
429
+
430
+ useEffect(() => {
431
+ const hostname = normalizeString(window.location.hostname).toLowerCase();
432
+ const isStaging = hostname.includes('localhost') || hostname.includes('127.0.0.1') || hostname.includes('staging') || hostname.includes('dev');
433
+ setEnvLabel(isStaging ? 'Staging' : 'Production');
434
+ }, []);
435
+
436
+ useEffect(() => {
437
+ document.body.classList.remove('compact');
438
+ try {
439
+ window.localStorage.removeItem('omnizap_admin_compact_mode_v1');
440
+ } catch {
441
+ // noop
442
+ }
443
+ }, []);
444
+
445
+ const [reloadTick, setReloadTick] = useState(0);
446
+
447
+ useEffect(() => {
448
+ let active = true;
449
+
450
+ const bootstrap = async () => {
451
+ setBusy(true);
452
+ setAdminError('');
453
+ try {
454
+ const [googlePayload, adminSessionPayload] = await Promise.all([api.getGoogleSession().catch(() => ({ data: null })), api.getAdminSession()]);
455
+
456
+ if (!active) return;
457
+
458
+ setGoogleSession(googlePayload?.data || null);
459
+ const statusData = adminSessionPayload?.data || null;
460
+ setAdminStatusPayload(statusData);
461
+
462
+ const isAuthenticated = Boolean(statusData?.session?.authenticated);
463
+ if (!isAuthenticated) {
464
+ setPreviousAdminOverviewPayload(null);
465
+ setAdminOverviewPayload(null);
466
+ return;
467
+ }
468
+
469
+ const overviewPayload = await api.getOverview();
470
+ if (!active) return;
471
+ setPreviousAdminOverviewPayload((current) => current || null);
472
+ setAdminOverviewPayload(overviewPayload?.data || null);
473
+ } catch (error) {
474
+ if (!active) return;
475
+ setAdminError(error?.message || 'Falha ao carregar painel admin.');
476
+ } finally {
477
+ if (active) {
478
+ setBusy(false);
479
+ }
480
+ }
481
+ };
482
+
483
+ void bootstrap();
484
+ return () => {
485
+ active = false;
486
+ };
487
+ }, [api, reloadTick]);
488
+
489
+ useEffect(() => {
490
+ setModerationPage(1);
491
+ }, [moderationSeverityFilter, moderationTypeFilter]);
492
+
493
+ useEffect(() => {
494
+ setAuditPage(1);
495
+ }, [auditStatusFilter, auditSearchQuery]);
496
+
497
+ const refreshPanel = useCallback(() => {
498
+ setReloadTick((value) => value + 1);
499
+ }, []);
500
+
501
+ const handleUnlockAdmin = async (event) => {
502
+ event.preventDefault();
503
+ if (busy) return;
504
+
505
+ const safePassword = normalizeString(unlockPassword);
506
+ if (!safePassword) {
507
+ setAdminError('Informe a senha do painel admin.');
508
+ return;
509
+ }
510
+
511
+ setBusy(true);
512
+ setAdminError('');
513
+ try {
514
+ await api.unlockAdmin(safePassword);
515
+ setUnlockPassword('');
516
+ pushToast({ kind: 'success', title: 'Admin', message: 'Área administrativa desbloqueada.' });
517
+ refreshPanel();
518
+ } catch (error) {
519
+ setAdminError(error?.message || 'Falha ao desbloquear área admin.');
520
+ pushToast({ kind: 'error', title: 'Erro', message: error?.message || 'Falha ao desbloquear área admin.' });
521
+ setBusy(false);
522
+ }
523
+ };
524
+
525
+ const handleAdminLogout = async () => {
526
+ if (busy) return;
527
+ if (!window.confirm('Encerrar sessão administrativa atual?')) return;
528
+
529
+ setBusy(true);
530
+ setAdminError('');
531
+ try {
532
+ await api.logoutAdmin();
533
+ setSearchResult(null);
534
+ pushToast({ kind: 'success', title: 'Admin', message: 'Sessão administrativa encerrada.' });
535
+ refreshPanel();
536
+ } catch (error) {
537
+ setAdminError(error?.message || 'Falha ao encerrar sessão admin.');
538
+ pushToast({ kind: 'error', title: 'Erro', message: error?.message || 'Falha ao encerrar sessão admin.' });
539
+ setBusy(false);
540
+ }
541
+ };
542
+
543
+ const handleRunOp = async (action, label) => {
544
+ if (!action || busy) return;
545
+ if (CRITICAL_OPS.has(action) && !window.confirm(`Executar ação crítica: ${label || action}?`)) {
546
+ return;
547
+ }
548
+
549
+ setBusy(true);
550
+ setAdminError('');
551
+ try {
552
+ const payload = await api.runOp(action);
553
+ const message = normalizeString(payload?.data?.message) || `Ação ${action} executada.`;
554
+ pushToast({ kind: 'success', title: 'Ops', message });
555
+ refreshPanel();
556
+ } catch (error) {
557
+ setAdminError(error?.message || 'Falha ao executar ação operacional.');
558
+ pushToast({ kind: 'error', title: 'Ops', message: error?.message || 'Falha ao executar ação operacional.' });
559
+ setBusy(false);
560
+ }
561
+ };
562
+
563
+ const handleSearchSubmit = async (event) => {
564
+ event.preventDefault();
565
+ if (busy) return;
566
+
567
+ const query = normalizeString(searchQuery);
568
+ if (!query) {
569
+ setSearchResult(null);
570
+ return;
571
+ }
572
+
573
+ setBusy(true);
574
+ setAdminError('');
575
+ try {
576
+ const payload = await api.search(query, 12);
577
+ setSearchResult(payload?.data || null);
578
+ pushToast({ kind: 'success', title: 'Busca', message: `Busca concluída para "${query}".` });
579
+ } catch (error) {
580
+ setAdminError(error?.message || 'Falha ao buscar dados.');
581
+ pushToast({ kind: 'error', title: 'Busca', message: error?.message || 'Falha ao buscar dados.' });
582
+ } finally {
583
+ setBusy(false);
584
+ }
585
+ };
586
+
587
+ const handleForceLogout = async (identity, contextLabel = '') => {
588
+ const payload = buildIdentityPayload({
589
+ sessionToken: identity?.session_token,
590
+ googleSub: identity?.google_sub,
591
+ email: identity?.email,
592
+ ownerJid: identity?.owner_jid,
593
+ });
594
+
595
+ if (!Object.keys(payload).length || busy) return;
596
+
597
+ const label = normalizeString(contextLabel) || buildIdentityLabel(payload);
598
+ if (!window.confirm(`Forçar logout de ${label}?`)) return;
599
+
600
+ setBusy(true);
601
+ setAdminError('');
602
+ try {
603
+ const response = await api.forceLogout(payload);
604
+ const removed = Number(response?.data?.removed_sessions || 0);
605
+ pushToast({ kind: 'success', title: 'Sessão', message: `Logout forçado concluído para ${label}. Sessões removidas: ${removed}.` });
606
+ refreshPanel();
607
+ } catch (error) {
608
+ setAdminError(error?.message || 'Falha ao forçar logout.');
609
+ pushToast({ kind: 'error', title: 'Erro', message: error?.message || 'Falha ao forçar logout.' });
610
+ setBusy(false);
611
+ }
612
+ };
613
+
614
+ const handleCreateBan = async (identity, reason = '') => {
615
+ const payload = {
616
+ ...buildIdentityPayload({
617
+ googleSub: identity?.google_sub,
618
+ email: identity?.email,
619
+ ownerJid: identity?.owner_jid,
620
+ }),
621
+ reason: normalizeString(reason) || 'Ban via painel administrativo.',
622
+ };
623
+
624
+ if (!payload.google_sub && !payload.email && !payload.owner_jid) return;
625
+ if (busy) return;
626
+
627
+ const label = buildIdentityLabel(payload);
628
+ if (!window.confirm(`Banir conta ${label}?`)) return;
629
+
630
+ setBusy(true);
631
+ setAdminError('');
632
+ try {
633
+ const response = await api.createBan(payload);
634
+ const created = Boolean(response?.data?.created);
635
+ pushToast({ kind: 'success', title: 'Ban', message: created ? `Conta ${label} banida.` : `Conta ${label} já estava bloqueada.` });
636
+ refreshPanel();
637
+ } catch (error) {
638
+ setAdminError(error?.message || 'Falha ao banir conta.');
639
+ pushToast({ kind: 'error', title: 'Erro', message: error?.message || 'Falha ao banir conta.' });
640
+ setBusy(false);
641
+ }
642
+ };
643
+
644
+ const handleRevokeBan = async (banId) => {
645
+ const safeBanId = normalizeString(banId);
646
+ if (!safeBanId || busy) return;
647
+ if (!window.confirm(`Revogar ban ${safeBanId}?`)) return;
648
+
649
+ setBusy(true);
650
+ setAdminError('');
651
+ try {
652
+ await api.revokeBan(safeBanId);
653
+ pushToast({ kind: 'success', title: 'Ban', message: `Ban ${safeBanId} revogado.` });
654
+ refreshPanel();
655
+ } catch (error) {
656
+ setAdminError(error?.message || 'Falha ao revogar ban.');
657
+ pushToast({ kind: 'error', title: 'Erro', message: error?.message || 'Falha ao revogar ban.' });
658
+ setBusy(false);
659
+ }
660
+ };
661
+
662
+ const handleToggleFeatureFlag = async (flag) => {
663
+ if (!flag || busy) return;
664
+
665
+ const flagName = normalizeString(flag?.flag_name);
666
+ if (!flagName) return;
667
+
668
+ setBusy(true);
669
+ setAdminError('');
670
+ try {
671
+ await api.upsertFeatureFlag({
672
+ flag_name: flagName,
673
+ is_enabled: !flag?.is_enabled,
674
+ rollout_percent: clampInt(flag?.rollout_percent, 0, 0, 100),
675
+ description: normalizeString(flag?.description),
676
+ });
677
+ pushToast({ kind: 'success', title: 'Feature flag', message: `Flag ${flagName} atualizada.` });
678
+ refreshPanel();
679
+ } catch (error) {
680
+ setAdminError(error?.message || 'Falha ao atualizar feature flag.');
681
+ pushToast({ kind: 'error', title: 'Erro', message: error?.message || 'Falha ao atualizar feature flag.' });
682
+ setBusy(false);
683
+ }
684
+ };
685
+
686
+ const handleExport = async (type, format) => {
687
+ if (busy) return;
688
+ const safeType = normalizeString(type).toLowerCase() || 'metrics';
689
+ const safeFormat = normalizeString(format).toLowerCase() === 'csv' ? 'csv' : 'json';
690
+
691
+ setBusy(true);
692
+ setAdminError('');
693
+ try {
694
+ const response = await api.exportDataRaw(safeType, safeFormat);
695
+ if (safeFormat === 'csv') {
696
+ const blob = await response.blob();
697
+ const disposition = response.headers.get('content-disposition');
698
+ const filename = extractFilenameFromDisposition(disposition, `admin-${safeType}.csv`);
699
+ triggerFileDownload(blob, filename);
700
+ } else {
701
+ const payload = await response.json().catch(() => ({}));
702
+ const blob = new Blob([JSON.stringify(payload?.data || payload || {}, null, 2)], {
703
+ type: 'application/json; charset=utf-8',
704
+ });
705
+ triggerFileDownload(blob, `admin-${safeType}.json`);
706
+ }
707
+ pushToast({ kind: 'success', title: 'Exportação', message: `Exportação ${safeType.toUpperCase()} (${safeFormat.toUpperCase()}) concluída.` });
708
+ } catch (error) {
709
+ setAdminError(error?.message || 'Falha ao exportar dados.');
710
+ pushToast({ kind: 'error', title: 'Exportação', message: error?.message || 'Falha ao exportar dados.' });
711
+ } finally {
712
+ setBusy(false);
713
+ }
714
+ };
715
+
716
+ const moderationFiltered = useMemo(() => {
717
+ const severityFilter = normalizeString(moderationSeverityFilter).toLowerCase();
718
+ const typeFilter = normalizeString(moderationTypeFilter).toLowerCase();
719
+ return moderationQueue.filter((event) => {
720
+ const eventSeverity = normalizeSeverity(event?.severity);
721
+ const eventType = normalizeString(event?.event_type).toLowerCase();
722
+ const severityOk = severityFilter === 'all' || eventSeverity === severityFilter;
723
+ const typeOk = typeFilter === 'all' || eventType.includes(typeFilter);
724
+ return severityOk && typeOk;
725
+ });
726
+ }, [moderationQueue, moderationSeverityFilter, moderationTypeFilter]);
727
+
728
+ const moderationPagination = useMemo(() => paginate({ items: moderationFiltered, page: moderationPage, pageSize: 6 }), [moderationFiltered, moderationPage]);
729
+
730
+ const usersPagination = useMemo(() => paginate({ items: users, page: usersPage, pageSize: 6 }), [users, usersPage]);
731
+ const sessionsPagination = useMemo(() => paginate({ items: sessions, page: sessionsPage, pageSize: 6 }), [sessions, sessionsPage]);
732
+
733
+ const auditFiltered = useMemo(() => {
734
+ const statusFilter = normalizeString(auditStatusFilter).toLowerCase();
735
+ const query = normalizeString(auditSearchQuery).toLowerCase();
736
+ return auditLog.filter((entry) => {
737
+ const status = normalizeString(entry?.status).toLowerCase();
738
+ const statusOk = statusFilter === 'all' || status === statusFilter;
739
+ if (!statusOk) return false;
740
+ if (!query) return true;
741
+ const haystack = [entry?.action, entry?.target_type, entry?.target_id, entry?.status, entry?.created_at].map((item) => normalizeString(item).toLowerCase()).join(' ');
742
+ return haystack.includes(query);
743
+ });
744
+ }, [auditLog, auditSearchQuery, auditStatusFilter]);
745
+
746
+ const auditPagination = useMemo(() => paginate({ items: auditFiltered, page: auditPage, pageSize: 6 }), [auditFiltered, auditPage]);
747
+ const alertsPagination = useMemo(() => paginate({ items: alerts, page: alertsPage, pageSize: 6 }), [alerts, alertsPage]);
748
+
749
+ const renderListItem = ({ title, severity = 'low', badgeLabel = '', meta = [], actions = [], customNode = null }) => {
750
+ const safeMeta = (Array.isArray(meta) ? meta : []).map((line) => normalizeString(line)).filter(Boolean);
751
+ return html`
752
+ <article className="admin-item">
753
+ <h5 className="admin-item-title">${normalizeString(title) || 'Registro'}</h5>
754
+ ${badgeLabel ? html`<${SeverityBadge} label=${badgeLabel} severity=${severity} />` : null} ${safeMeta.map((line, index) => html`<p key=${`meta-${index}`} className="admin-item-meta">${line}</p>`)} ${customNode ? customNode : null} ${actions.length ? html` <div className="admin-item-actions">${actions.map((action, index) => (action.kind === 'link' ? html`<a key=${`action-${index}`} className="admin-mini-btn" href=${action.href} target="_blank" rel="noreferrer noopener">${action.label}</a>` : html`<button key=${`action-${index}`} type="button" className="admin-mini-btn" disabled=${Boolean(action.disabled) || busy} onClick=${action.onClick}>${action.label}</button>`))}</div> ` : null}
755
+ </article>
756
+ `;
757
+ };
758
+
759
+ const stickersBasePath = config.stickersPath.replace(/\/+$/, '') || DEFAULT_STICKERS_PATH;
760
+ const stickersManagePath = `${stickersBasePath}/perfil`;
761
+
762
+ return html`
763
+ <main className="admin-shell" data-sidebar="expanded">
764
+ <aside className="sidebar">
765
+ <a href="/" className="brand">
766
+ <img src=${FALLBACK_AVATAR} alt="OmniZap" loading="lazy" decoding="async" />
767
+ <span>Omnizap</span>
768
+ </a>
769
+
770
+ <ul className="nav-list">
771
+ ${NAV_ITEMS.map(
772
+ (item) => html`
773
+ <li key=${item.id}>
774
+ <a
775
+ className=${`nav-link ${activePage === item.id ? 'active' : ''}`}
776
+ href=${`#${item.id}`}
777
+ onClick=${(event) => {
778
+ event.preventDefault();
779
+ navigateToPage(item.id);
780
+ }}
781
+ >
782
+ <span>${item.label}</span>
783
+ <span className="nav-kbd">${item.kbd}</span>
784
+ </a>
785
+ </li>
786
+ `,
787
+ )}
788
+ </ul>
789
+
790
+ <div className="sidebar-footer">
791
+ <a className="btn" href="/"> Home </a>
792
+ <a className="btn" href="/user/"> Minha Conta </a>
793
+ <a className="btn" href=${stickersManagePath}> Gerenciar Stickers </a>
794
+ </div>
795
+ </aside>
796
+
797
+ <div className="workspace">
798
+ <header className="topbar">
799
+ <div className="topbar-left">
800
+ <h2 className="topbar-title">System Admin</h2>
801
+ <div className="topbar-meta">
802
+ <span className="chip env">${envLabel}</span>
803
+ <span className=${`chip status ${normalizeStatusTone(globalStatus.tone) !== 'online' ? normalizeStatusTone(globalStatus.tone) : ''}`}>
804
+ <span className="top-status-dot"></span>
805
+ <span>${globalStatus.label}</span>
806
+ </span>
807
+ </div>
808
+ </div>
809
+
810
+ <div className="topbar-right">
811
+ <div className="topbar-admin">
812
+ <img src=${profilePicture} alt="Admin" />
813
+ <span>${profileName}</span>
814
+ </div>
815
+ </div>
816
+ </header>
817
+
818
+ <div className="viewport">
819
+ <section className="section admin-panel">
820
+ ${!adminAuthenticated ? html`<p className="admin-note">${adminEligible ? 'Conta elegível para admin. Informe a senha para liberar os dados sensíveis.' : 'Conta atual sem permissão para o painel admin.'}</p>` : null} ${adminError ? html` <p className="admin-error">${adminError}</p> ` : null}
821
+ ${!adminEligible
822
+ ? html`
823
+ <div className="admin-item">
824
+ <h5 className="admin-item-title">Acesso restrito</h5>
825
+ <p className="admin-item-meta">Faça login com a conta Google autorizada para acessar o painel.</p>
826
+ <div className="admin-item-actions">
827
+ <a className="btn" href=${`${config.loginPath}/`}> Ir para login </a>
828
+ </div>
829
+ </div>
830
+ `
831
+ : null}
832
+ ${adminEligible && !adminAuthenticated
833
+ ? html`
834
+ <form className="admin-form" onSubmit=${handleUnlockAdmin}>
835
+ <label className="admin-label" for="admin-password-input">Senha do painel admin</label>
836
+ <div className="admin-form-row">
837
+ <input id="admin-password-input" className="admin-input" type="password" autocomplete="current-password" placeholder="Digite sua senha" value=${unlockPassword} disabled=${busy} onInput=${(event) => setUnlockPassword(event.target.value)} />
838
+ <button type="submit" className="btn" disabled=${busy}>${busy ? 'Desbloqueando...' : 'Desbloquear'}</button>
839
+ </div>
840
+ </form>
841
+ `
842
+ : null}
843
+ ${adminAuthenticated
844
+ ? html`
845
+ <div className="admin-layout admin-layout--enterprise is-subpage">
846
+ <section id="overview" className="section section-kpis span-12" hidden=${activePage !== 'overview'}>
847
+ <div className="section-head">
848
+ <div>
849
+ <h4 className="panel-title">Dashboard Estratégico</h4>
850
+ <p className="panel-subtitle">Métricas segmentadas por Operação, Sistema, Segurança e Usuários.</p>
851
+ </div>
852
+ </div>
853
+
854
+ <div className="admin-grid">
855
+ <article className="metric-card">
856
+ <p className="admin-metric-label">Bots online</p>
857
+ <p className="admin-metric-value">${formatNumber(dashboardQuick?.bots_online)}</p>
858
+ <span className="trend up">Operação</span>
859
+ <p className="metric-context">vs leitura anterior: ${deltaLabel(dashboardQuick?.bots_online, previousOverview?.dashboard_quick?.bots_online ?? dashboardQuick?.bots_online)}</p>
860
+ </article>
861
+ <article className="metric-card">
862
+ <p className="admin-metric-label">Mensagens hoje</p>
863
+ <p className="admin-metric-value">${formatNumber(dashboardQuick?.messages_today)}</p>
864
+ <span className="trend up">Operação</span>
865
+ <p className="metric-context">vs leitura anterior: ${deltaLabel(dashboardQuick?.messages_today, previousOverview?.dashboard_quick?.messages_today ?? dashboardQuick?.messages_today)}</p>
866
+ </article>
867
+ <article className="metric-card system">
868
+ <p className="admin-metric-label">Uptime</p>
869
+ <p className="admin-metric-value">${normalizeString(dashboardQuick?.uptime) || 'n/d'}</p>
870
+ <span className="trend up">Sistema</span>
871
+ <p className="metric-context">janela: processo atual</p>
872
+ </article>
873
+ <article className="metric-card system">
874
+ <p className="admin-metric-label">Erros 5xx</p>
875
+ <p className="admin-metric-value">${formatNumber(dashboardQuick?.errors_5xx)}</p>
876
+ <span className="trend warn">Sistema</span>
877
+ <p className="metric-context">vs leitura anterior: ${deltaLabel(dashboardQuick?.errors_5xx, previousOverview?.dashboard_quick?.errors_5xx ?? dashboardQuick?.errors_5xx, { percent: false, suffix: ' eventos' })}</p>
878
+ </article>
879
+ <article className="metric-card">
880
+ <p className="admin-metric-label">Packs (total)</p>
881
+ <p className="admin-metric-value">${formatNumber(counters?.total_packs_any_status)}</p>
882
+ <span className="trend up">Produto</span>
883
+ <p className="metric-context">delta leitura: ${deltaLabel(counters?.total_packs_any_status, previousOverview?.counters?.total_packs_any_status ?? counters?.total_packs_any_status, { percent: false })}</p>
884
+ </article>
885
+ <article className="metric-card">
886
+ <p className="admin-metric-label">Stickers (total)</p>
887
+ <p className="admin-metric-value">${formatNumber(counters?.total_stickers_any_status)}</p>
888
+ <span className="trend up">Produto</span>
889
+ <p className="metric-context">delta leitura: ${deltaLabel(counters?.total_stickers_any_status, previousOverview?.counters?.total_stickers_any_status ?? counters?.total_stickers_any_status, { percent: false })}</p>
890
+ </article>
891
+ <article className="metric-card security warning">
892
+ <p className="admin-metric-label">Spam bloqueado</p>
893
+ <p className="admin-metric-value">${formatNumber(dashboardQuick?.spam_blocked_today)}</p>
894
+ <span className="trend warn">Segurança</span>
895
+ <p className="metric-context">vs leitura anterior: ${deltaLabel(dashboardQuick?.spam_blocked_today, previousOverview?.dashboard_quick?.spam_blocked_today ?? dashboardQuick?.spam_blocked_today)}</p>
896
+ </article>
897
+ <article className="metric-card security critical">
898
+ <p className="admin-metric-label">Bans ativos</p>
899
+ <p className="admin-metric-value">${formatNumber(counters?.active_bans)}</p>
900
+ <span className="trend down">Segurança</span>
901
+ <p className="metric-context">delta leitura: ${deltaLabel(counters?.active_bans, previousOverview?.counters?.active_bans ?? counters?.active_bans, { percent: false })}</p>
902
+ </article>
903
+ <article className="metric-card users">
904
+ <p className="admin-metric-label">Usuários Google</p>
905
+ <p className="admin-metric-value">${formatNumber(counters?.known_google_users)}</p>
906
+ <span className="trend up">Usuários</span>
907
+ <p className="metric-context">delta leitura: ${deltaLabel(counters?.known_google_users, previousOverview?.counters?.known_google_users ?? counters?.known_google_users, { percent: false })}</p>
908
+ </article>
909
+ <article className="metric-card users">
910
+ <p className="admin-metric-label">Sessões Google</p>
911
+ <p className="admin-metric-value">${formatNumber(counters?.active_google_sessions)}</p>
912
+ <span className="trend warn">Usuários</span>
913
+ <p className="metric-context">delta leitura: ${deltaLabel(counters?.active_google_sessions, previousOverview?.counters?.active_google_sessions ?? counters?.active_google_sessions, { percent: false })}</p>
914
+ </article>
915
+ <article className="metric-card users">
916
+ <p className="admin-metric-label">Visitas 24h</p>
917
+ <p className="admin-metric-value">${formatNumber(counters?.visit_events_24h)}</p>
918
+ <span className="trend up">Usuários</span>
919
+ <p className="metric-context">janela: 24h</p>
920
+ </article>
921
+ <article className="metric-card users">
922
+ <p className="admin-metric-label">Visitas 7d</p>
923
+ <p className="admin-metric-value">${formatNumber(counters?.visit_events_7d)}</p>
924
+ <span className="trend up">Usuários</span>
925
+ <p className="metric-context">janela: 7 dias</p>
926
+ </article>
927
+ <article className="metric-card users">
928
+ <p className="admin-metric-label">Visitantes 7d</p>
929
+ <p className="admin-metric-value">${formatNumber(counters?.unique_visitors_7d)}</p>
930
+ <span className="trend up">Usuários</span>
931
+ <p className="metric-context">janela: 7 dias</p>
932
+ </article>
933
+ <article className=${grafanaStatusCardClass}>
934
+ <p className="admin-metric-label">Grafana status</p>
935
+ <p className="admin-metric-value">${grafanaStatusLabel}</p>
936
+ <span className=${`trend ${grafanaStatusTrend}`}>Observabilidade</span>
937
+ <p className="metric-context">checado: ${grafanaCheckedAtLabel} · latência: ${grafanaResponseLabel}</p>
938
+ </article>
939
+ <article className="metric-card system">
940
+ <p className="admin-metric-label">Grafana versão</p>
941
+ <p className="admin-metric-value">${grafanaVersion}</p>
942
+ <span className="trend up">Observabilidade</span>
943
+ <p className="metric-context">database: ${grafanaDatabase}</p>
944
+ </article>
945
+ <article className="metric-card">
946
+ <p className="admin-metric-label">Dashboards Grafana</p>
947
+ <p className="admin-metric-value">${formatNumber(grafanaDashboardsTotal)}</p>
948
+ <span className="trend up">Observabilidade</span>
949
+ <p className="metric-context">painéis configurados no admin</p>
950
+ ${grafanaPrimaryUrl
951
+ ? html`
952
+ <div className="admin-item-actions" style=${{ marginTop: '8px' }}>
953
+ <a className="admin-mini-btn" href=${grafanaPrimaryUrl} target="_blank" rel="noreferrer noopener">Abrir Grafana</a>
954
+ </div>
955
+ `
956
+ : null}
957
+ </article>
958
+ </div>
959
+ </section>
960
+
961
+ <section id="saude" className="section section-health span-12" hidden=${activePage !== 'saude'}>
962
+ <div className="section-head">
963
+ <div>
964
+ <h4 className="panel-title">Saúde do Sistema</h4>
965
+ <p className="panel-subtitle">Visão DevOps com barras de utilização e status de banco.</p>
966
+ </div>
967
+ </div>
968
+ <div className="health-grid">
969
+ <article className="health-card">
970
+ <div className="health-head">
971
+ <p className="admin-metric-label">CPU</p>
972
+ <p className="admin-metric-value">${formatPercent(systemHealth?.cpu_percent)}</p>
973
+ </div>
974
+ <div className="health-meter">
975
+ <span className=${Number(systemHealth?.cpu_percent || 0) >= 88 ? 'danger' : Number(systemHealth?.cpu_percent || 0) >= 75 ? 'warn' : ''} style=${{ inlineSize: `${Math.max(0, Math.min(100, Number(systemHealth?.cpu_percent || 0))).toFixed(1)}%` }}></span>
976
+ </div>
977
+ <p className="health-meta">Limite alerta: 88%</p>
978
+ </article>
979
+
980
+ <article className="health-card">
981
+ <div className="health-head">
982
+ <p className="admin-metric-label">RAM</p>
983
+ <p className="admin-metric-value">${formatPercent(systemHealth?.ram_percent)}</p>
984
+ </div>
985
+ <div className="health-meter">
986
+ <span className=${Number(systemHealth?.ram_percent || 0) >= 90 ? 'danger' : Number(systemHealth?.ram_percent || 0) >= 75 ? 'warn' : ''} style=${{ inlineSize: `${Math.max(0, Math.min(100, Number(systemHealth?.ram_percent || 0))).toFixed(1)}%` }}></span>
987
+ </div>
988
+ <p className="health-meta">Limite alerta: 90%</p>
989
+ </article>
990
+
991
+ <article className="health-card">
992
+ <div className="health-head">
993
+ <p className="admin-metric-label">Latência P95</p>
994
+ <p className="admin-metric-value">${formatMilliseconds(systemHealth?.http_latency_p95_ms)}</p>
995
+ </div>
996
+ <div className="health-meter">
997
+ <span className=${Number(systemHealth?.http_latency_p95_ms || 0) >= 500 ? 'danger' : Number(systemHealth?.http_latency_p95_ms || 0) >= 300 ? 'warn' : ''} style=${{ inlineSize: `${Math.max(0, Math.min(100, (Number(systemHealth?.http_latency_p95_ms || 0) / 900) * 100)).toFixed(1)}%` }}></span>
998
+ </div>
999
+ <p className="health-meta">Alerta: &gt; 300ms</p>
1000
+ </article>
1001
+
1002
+ <article className="health-card">
1003
+ <div className="health-head">
1004
+ <p className="admin-metric-label">Fila pendente</p>
1005
+ <p className="admin-metric-value">${formatNumber(systemHealth?.queue_pending)}</p>
1006
+ </div>
1007
+ <div className="health-meter">
1008
+ <span className=${Number(systemHealth?.queue_pending || 0) >= 220 ? 'danger' : Number(systemHealth?.queue_pending || 0) >= 120 ? 'warn' : ''} style=${{ inlineSize: `${Math.max(0, Math.min(100, (Number(systemHealth?.queue_pending || 0) / 400) * 100)).toFixed(1)}%` }}></span>
1009
+ </div>
1010
+ <p className="health-meta">Ideal: &lt; 120 jobs</p>
1011
+ </article>
1012
+
1013
+ <article className="health-card">
1014
+ <div className="health-head">
1015
+ <p className="admin-metric-label">Banco</p>
1016
+ <span className=${`db-badge ${normalizeString(systemHealth?.db_status).toLowerCase() === 'ok' ? 'healthy' : normalizeString(systemHealth?.db_status).toLowerCase() === 'degraded' ? 'degraded' : normalizeString(systemHealth?.db_status).toLowerCase() === 'down' ? 'down' : ''}`}>${normalizeString(systemHealth?.db_status) || 'unknown'}</span>
1017
+ </div>
1018
+ <p className="health-meta">SLA alvo: 99.95%</p>
1019
+ </article>
1020
+ </div>
1021
+
1022
+ <div className="section" style=${{ marginTop: '16px' }}>
1023
+ <div className="section-head">
1024
+ <div>
1025
+ <h4 className="panel-title">Dashboards Grafana</h4>
1026
+ <p className="panel-subtitle">Visualização incorporada dos dashboards de observabilidade.</p>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ ${grafanaDashboards.length
1031
+ ? html`
1032
+ <div className="admin-list">
1033
+ ${grafanaDashboards.map((dashboard, index) => {
1034
+ const uid = normalizeString(dashboard?.uid) || `dashboard-${index + 1}`;
1035
+ const title = normalizeString(dashboard?.title) || uid;
1036
+ const viewUrl = normalizeString(dashboard?.view_url);
1037
+ const embedUrl = normalizeString(dashboard?.embed_url || viewUrl);
1038
+ if (!embedUrl) return null;
1039
+ return html`
1040
+ <article key=${uid} className="admin-item">
1041
+ <h5 className="admin-item-title">${title}</h5>
1042
+ <p className="admin-item-meta">UID: ${uid}</p>
1043
+ ${viewUrl
1044
+ ? html`
1045
+ <div className="admin-item-actions">
1046
+ <a className="admin-mini-btn" href=${viewUrl} target="_blank" rel="noreferrer noopener">Abrir no Grafana</a>
1047
+ </div>
1048
+ `
1049
+ : null}
1050
+ <div style=${{ marginTop: '10px', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(148, 163, 184, 0.25)', background: '#020617' }}>
1051
+ <iframe title=${`grafana-${uid}`} src=${embedUrl} loading="lazy" referrerpolicy="no-referrer" style=${{ inlineSize: '100%', blockSize: '420px', border: '0', background: '#020617' }}></iframe>
1052
+ </div>
1053
+ </article>
1054
+ `;
1055
+ })}
1056
+ </div>
1057
+ `
1058
+ : html`<p className="admin-item-meta">Sem dashboards configurados. Defina SYSTEM_ADMIN_GRAFANA_URL e SYSTEM_ADMIN_GRAFANA_DASHBOARDS no ambiente.</p>`}
1059
+ </div>
1060
+ </section>
1061
+
1062
+ <section id="moderacao" className="section section-security span-12" hidden=${activePage !== 'moderacao'}>
1063
+ <div className="section-head">
1064
+ <div>
1065
+ <h4 className="panel-title">Fila de Moderação</h4>
1066
+ <p className="panel-subtitle">Eventos recentes para ação rápida do time.</p>
1067
+ </div>
1068
+ </div>
1069
+
1070
+ <div className="filters">
1071
+ <div className="filter-field">
1072
+ <label>Severidade</label>
1073
+ <select className="admin-input" value=${moderationSeverityFilter} onChange=${(event) => setModerationSeverityFilter(event.target.value)}>
1074
+ <option value="all">Todas</option>
1075
+ <option value="critical">Crítica</option>
1076
+ <option value="high">Alta</option>
1077
+ <option value="medium">Média</option>
1078
+ <option value="low">Baixa</option>
1079
+ </select>
1080
+ </div>
1081
+ <div className="filter-field">
1082
+ <label>Tipo</label>
1083
+ <select className="admin-input" value=${moderationTypeFilter} onChange=${(event) => setModerationTypeFilter(event.target.value)}>
1084
+ <option value="all">Todos</option>
1085
+ <option value="ban">Ban</option>
1086
+ <option value="spam">Spam</option>
1087
+ <option value="abuse">Abuse</option>
1088
+ <option value="incident">Incident</option>
1089
+ </select>
1090
+ </div>
1091
+ </div>
1092
+
1093
+ <div className="admin-list timeline">
1094
+ ${moderationPagination.pageItems.length
1095
+ ? moderationPagination.pageItems.map((event) => {
1096
+ const severity = normalizeSeverity(event?.severity);
1097
+ const title = normalizeString(event?.title) || 'Evento de moderação';
1098
+ const meta = [normalizeString(event?.subtitle), `Tipo: ${normalizeString(event?.event_type) || 'evento'} · ${formatDateTime(event?.created_at || event?.revoked_at)}`, normalizeString(event?.reason) ? `Motivo: ${normalizeString(event?.reason)}` : ''].filter(Boolean);
1099
+
1100
+ const actions = [];
1101
+ if (normalizeString(event?.event_type).toLowerCase() === 'ban' && event?.ban_id && !event?.revoked_at) {
1102
+ actions.push({
1103
+ label: 'Revogar ban',
1104
+ onClick: () => handleRevokeBan(event?.ban_id),
1105
+ });
1106
+ } else {
1107
+ const identity = buildIdentityPayload({
1108
+ sessionToken: event?.metadata?.session_token,
1109
+ googleSub: event?.metadata?.google_sub,
1110
+ email: event?.metadata?.email,
1111
+ ownerJid: event?.sender_id || event?.metadata?.owner_jid,
1112
+ });
1113
+
1114
+ if (Object.keys(identity).length) {
1115
+ actions.push({
1116
+ label: 'Banir conta',
1117
+ onClick: () =>
1118
+ handleCreateBan(
1119
+ {
1120
+ google_sub: identity.google_sub,
1121
+ email: identity.email,
1122
+ owner_jid: identity.owner_jid,
1123
+ },
1124
+ `Ban via moderação (${normalizeString(event?.event_type) || 'evento'})`,
1125
+ ),
1126
+ });
1127
+ actions.push({
1128
+ label: 'Forçar logout',
1129
+ onClick: () => handleForceLogout(identity, buildIdentityLabel(identity)),
1130
+ });
1131
+ }
1132
+ }
1133
+
1134
+ return renderListItem({
1135
+ title,
1136
+ severity,
1137
+ badgeLabel: severity.toUpperCase(),
1138
+ meta,
1139
+ actions,
1140
+ });
1141
+ })
1142
+ : html`<p className="admin-item-meta">Nenhum evento recente de moderação.</p>`}
1143
+ </div>
1144
+
1145
+ <${PaginationControls} pagination=${moderationPagination} onPrev=${() => setModerationPage((value) => Math.max(1, value - 1))} onNext=${() => setModerationPage((value) => value + 1)} />
1146
+ </section>
1147
+
1148
+ <section id="usuarios" className="section section-users span-12" hidden=${activePage !== 'usuarios'}>
1149
+ <div className="section-head">
1150
+ <div>
1151
+ <h4 className="panel-title">Usuários</h4>
1152
+ <p className="panel-subtitle">Contas conhecidas e ações de moderação.</p>
1153
+ </div>
1154
+ </div>
1155
+
1156
+ <div className="admin-list">
1157
+ ${usersPagination.pageItems.length
1158
+ ? usersPagination.pageItems.map((user) => {
1159
+ const identity = buildIdentityPayload({
1160
+ googleSub: user?.google_sub,
1161
+ email: user?.email,
1162
+ ownerJid: user?.owner_jid,
1163
+ });
1164
+ return renderListItem({
1165
+ title: normalizeString(user?.name || user?.email || user?.owner_jid) || 'Usuário',
1166
+ severity: 'low',
1167
+ badgeLabel: 'USER',
1168
+ meta: [`Email: ${normalizeString(user?.email) || 'n/d'}`, `Owner: ${normalizeString(user?.owner_jid) || 'n/d'}`, `Último acesso: ${formatDateTime(user?.last_seen_at || user?.last_login_at)}`],
1169
+ actions: [
1170
+ {
1171
+ label: 'Forçar logout',
1172
+ onClick: () => handleForceLogout(identity, buildIdentityLabel(identity)),
1173
+ },
1174
+ {
1175
+ label: 'Banir conta',
1176
+ onClick: () => handleCreateBan(identity, 'Ban via lista de usuários.'),
1177
+ },
1178
+ ],
1179
+ });
1180
+ })
1181
+ : html`<p className="admin-item-meta">Nenhum usuário encontrado.</p>`}
1182
+ </div>
1183
+
1184
+ <${PaginationControls} pagination=${usersPagination} onPrev=${() => setUsersPage((value) => Math.max(1, value - 1))} onNext=${() => setUsersPage((value) => value + 1)} />
1185
+
1186
+ <div className="section" style=${{ marginTop: '16px' }}>
1187
+ <div className="section-head">
1188
+ <div>
1189
+ <h4 className="panel-title">Busca Global</h4>
1190
+ <p className="panel-subtitle">Resultados consolidados por usuário, grupo, pack e sessão.</p>
1191
+ </div>
1192
+ </div>
1193
+ <form className="admin-inline-form" onSubmit=${handleSearchSubmit} style=${{ marginBottom: '12px' }}>
1194
+ <input className="admin-input" type="text" placeholder="Buscar por usuário, grupo, pack ou sessão" value=${searchQuery} onInput=${(event) => setSearchQuery(event.target.value)} disabled=${busy} />
1195
+ <button type="submit" className="btn" disabled=${busy}>Buscar</button>
1196
+ </form>
1197
+ <div className="admin-list">
1198
+ ${searchResult
1199
+ ? (() => {
1200
+ const rows = [];
1201
+ const usersRows = Array.isArray(searchResult?.results?.users) ? searchResult.results.users : [];
1202
+ const sessionsRows = Array.isArray(searchResult?.results?.sessions) ? searchResult.results.sessions : [];
1203
+ const groupsRows = Array.isArray(searchResult?.results?.groups) ? searchResult.results.groups : [];
1204
+ const packsRows = Array.isArray(searchResult?.results?.packs) ? searchResult.results.packs : [];
1205
+
1206
+ for (const row of usersRows) {
1207
+ const identity = buildIdentityPayload({
1208
+ googleSub: row?.google_sub,
1209
+ email: row?.email,
1210
+ ownerJid: row?.owner_jid,
1211
+ });
1212
+ rows.push(
1213
+ renderListItem({
1214
+ title: `[Usuário] ${normalizeString(row?.name || row?.email || row?.owner_jid) || 'registro'}`,
1215
+ severity: 'low',
1216
+ badgeLabel: 'USER',
1217
+ meta: [`Email: ${normalizeString(row?.email) || 'n/d'}`, `Owner: ${normalizeString(row?.owner_jid) || 'n/d'}`],
1218
+ actions: [
1219
+ {
1220
+ label: 'Forçar logout',
1221
+ onClick: () => handleForceLogout(identity, buildIdentityLabel(identity)),
1222
+ },
1223
+ ],
1224
+ }),
1225
+ );
1226
+ }
1227
+
1228
+ for (const row of sessionsRows) {
1229
+ const identity = buildIdentityPayload({
1230
+ sessionToken: row?.session_token,
1231
+ googleSub: row?.google_sub,
1232
+ email: row?.email,
1233
+ ownerJid: row?.owner_jid,
1234
+ });
1235
+ rows.push(
1236
+ renderListItem({
1237
+ title: `[Sessão] ${normalizeString(row?.name || row?.email || row?.owner_jid) || 'ativa'}`,
1238
+ severity: 'low',
1239
+ badgeLabel: 'SESSÃO',
1240
+ meta: [`Email: ${normalizeString(row?.email) || 'n/d'}`, `Expira: ${formatDateTime(row?.expires_at)}`],
1241
+ actions: [
1242
+ {
1243
+ label: 'Forçar logout',
1244
+ onClick: () => handleForceLogout(identity, buildIdentityLabel(identity)),
1245
+ },
1246
+ ],
1247
+ }),
1248
+ );
1249
+ }
1250
+
1251
+ for (const row of groupsRows) {
1252
+ rows.push(
1253
+ renderListItem({
1254
+ title: `[Grupo] ${normalizeString(row?.subject || row?.id) || 'grupo'}`,
1255
+ severity: 'medium',
1256
+ badgeLabel: 'GRUPO',
1257
+ meta: [`ID: ${normalizeString(row?.id) || 'n/d'}`, `Atualizado: ${formatDateTime(row?.updated_at)}`],
1258
+ }),
1259
+ );
1260
+ }
1261
+
1262
+ for (const row of packsRows) {
1263
+ const actions = [];
1264
+ if (normalizeString(row?.web_url)) {
1265
+ actions.push({ kind: 'link', label: 'Abrir pack', href: row.web_url });
1266
+ }
1267
+ rows.push(
1268
+ renderListItem({
1269
+ title: `[Pack] ${normalizeString(row?.name || row?.pack_key) || 'pack'}`,
1270
+ severity: 'low',
1271
+ badgeLabel: normalizeString(row?.visibility || 'pack').toUpperCase(),
1272
+ meta: [`Owner: ${normalizeString(row?.owner_jid) || 'n/d'}`, `Stickers: ${formatNumber(row?.stickers_count)}`],
1273
+ actions,
1274
+ }),
1275
+ );
1276
+ }
1277
+
1278
+ if (!rows.length) {
1279
+ return html`<p className="admin-item-meta">Nenhum resultado encontrado.</p>`;
1280
+ }
1281
+ return rows;
1282
+ })()
1283
+ : html`<p className="admin-item-meta">Faça uma busca para ver usuários, grupos, packs e sessões.</p>`}
1284
+ </div>
1285
+ </div>
1286
+ </section>
1287
+
1288
+ <section id="sessoes" className="section section-users span-12" hidden=${activePage !== 'sessoes'}>
1289
+ <div className="section-head">
1290
+ <div>
1291
+ <h4 className="panel-title">Sessões Ativas</h4>
1292
+ <p className="panel-subtitle">Sessões Google abertas e monitoradas.</p>
1293
+ </div>
1294
+ </div>
1295
+
1296
+ <div className="admin-list">
1297
+ ${sessionsPagination.pageItems.length
1298
+ ? sessionsPagination.pageItems.map((sessionEntry) => {
1299
+ const identity = buildIdentityPayload({
1300
+ sessionToken: sessionEntry?.session_token,
1301
+ googleSub: sessionEntry?.google_sub,
1302
+ email: sessionEntry?.email,
1303
+ ownerJid: sessionEntry?.owner_jid,
1304
+ });
1305
+ return renderListItem({
1306
+ title: normalizeString(sessionEntry?.name || sessionEntry?.email || sessionEntry?.owner_jid) || 'Sessão ativa',
1307
+ severity: 'medium',
1308
+ badgeLabel: 'SESSÃO',
1309
+ meta: [`Email: ${normalizeString(sessionEntry?.email) || 'n/d'}`, `Token: ${normalizeString(sessionEntry?.session_token) || 'n/d'}`, `Última atividade: ${formatDateTime(sessionEntry?.last_seen_at)}`, `Expira: ${formatDateTime(sessionEntry?.expires_at)}`],
1310
+ actions: [
1311
+ {
1312
+ label: 'Forçar logout',
1313
+ onClick: () => handleForceLogout(identity, buildIdentityLabel(identity)),
1314
+ },
1315
+ ],
1316
+ });
1317
+ })
1318
+ : html`<p className="admin-item-meta">Nenhuma sessão ativa encontrada.</p>`}
1319
+ </div>
1320
+
1321
+ <${PaginationControls} pagination=${sessionsPagination} onPrev=${() => setSessionsPage((value) => Math.max(1, value - 1))} onNext=${() => setSessionsPage((value) => value + 1)} />
1322
+ </section>
1323
+
1324
+ <section id="auditoria" className="section section-governance span-12" hidden=${activePage !== 'auditoria'}>
1325
+ <div className="section-head">
1326
+ <div>
1327
+ <h4 className="panel-title">Auditoria</h4>
1328
+ <p className="panel-subtitle">Registro de ações administrativas recentes.</p>
1329
+ </div>
1330
+ </div>
1331
+
1332
+ <div className="filters">
1333
+ <div className="filter-field">
1334
+ <label>Status</label>
1335
+ <select className="admin-input" value=${auditStatusFilter} onChange=${(event) => setAuditStatusFilter(event.target.value)}>
1336
+ <option value="all">Todos</option>
1337
+ <option value="success">Sucesso</option>
1338
+ <option value="failed">Falha</option>
1339
+ </select>
1340
+ </div>
1341
+ <div className="filter-field" style=${{ minInlineSize: '220px' }}>
1342
+ <label>Buscar</label>
1343
+ <input className="admin-input" type="text" value=${auditSearchQuery} onInput=${(event) => setAuditSearchQuery(event.target.value)} placeholder="Ação, alvo ou status" />
1344
+ </div>
1345
+ </div>
1346
+
1347
+ <div className="admin-list timeline">
1348
+ ${auditPagination.pageItems.length
1349
+ ? auditPagination.pageItems.map((entry) =>
1350
+ renderListItem({
1351
+ title: normalizeString(entry?.action) || 'Ação administrativa',
1352
+ severity: normalizeString(entry?.status).toLowerCase() === 'failed' ? 'high' : 'low',
1353
+ badgeLabel: normalizeString(entry?.status || 'status').toUpperCase(),
1354
+ meta: [`Alvo: ${normalizeString(entry?.target_type || 'n/d')} · ${normalizeString(entry?.target_id || 'n/d')}`, `Autor: ${normalizeString(entry?.actor_email || entry?.actor_sub || 'n/d')}`, `Quando: ${formatDateTime(entry?.created_at)}`],
1355
+ }),
1356
+ )
1357
+ : html`<p className="admin-item-meta">Nenhum evento de auditoria no período.</p>`}
1358
+ </div>
1359
+
1360
+ <${PaginationControls} pagination=${auditPagination} onPrev=${() => setAuditPage((value) => Math.max(1, value - 1))} onNext=${() => setAuditPage((value) => value + 1)} />
1361
+ </section>
1362
+
1363
+ <section id="alertas" className="section section-security span-12" hidden=${activePage !== 'alertas'}>
1364
+ <div className="section-head">
1365
+ <div>
1366
+ <h4 className="panel-title">Alertas</h4>
1367
+ <p className="panel-subtitle">Monitoramento de risco e estabilidade.</p>
1368
+ </div>
1369
+ </div>
1370
+
1371
+ <div className="admin-list">
1372
+ ${alertsPagination.pageItems.length
1373
+ ? alertsPagination.pageItems.map((alert) =>
1374
+ renderListItem({
1375
+ title: normalizeString(alert?.title || alert?.code) || 'Alerta',
1376
+ severity: normalizeSeverity(alert?.severity),
1377
+ badgeLabel: normalizeSeverity(alert?.severity).toUpperCase(),
1378
+ meta: [normalizeString(alert?.description) || 'Sem descrição.', `Código: ${normalizeString(alert?.code || 'n/d')}`],
1379
+ }),
1380
+ )
1381
+ : html`<p className="admin-item-meta">Sem alertas ativos no momento.</p>`}
1382
+ </div>
1383
+
1384
+ <${PaginationControls} pagination=${alertsPagination} onPrev=${() => setAlertsPage((value) => Math.max(1, value - 1))} onNext=${() => setAlertsPage((value) => value + 1)} />
1385
+ </section>
1386
+
1387
+ <section id="configuracoes" className="section section-governance span-6" hidden=${activePage !== 'configuracoes'}>
1388
+ <div className="section-head">
1389
+ <div>
1390
+ <h4 className="panel-title">Controle de Recursos</h4>
1391
+ <p className="panel-subtitle">Feature flags e ajustes de rollout.</p>
1392
+ </div>
1393
+ </div>
1394
+
1395
+ <div className="admin-list">
1396
+ ${featureFlags.length
1397
+ ? featureFlags.map((flag) =>
1398
+ renderListItem({
1399
+ title: normalizeString(flag?.flag_name) || 'flag',
1400
+ severity: flag?.is_enabled ? 'medium' : 'low',
1401
+ badgeLabel: flag?.is_enabled ? 'ON' : 'OFF',
1402
+ meta: [normalizeString(flag?.description) || 'Sem descrição.', `Rollout: ${formatNumber(flag?.rollout_percent || 0)}%`],
1403
+ actions: [
1404
+ {
1405
+ label: flag?.is_enabled ? 'Desativar' : 'Ativar',
1406
+ onClick: () => handleToggleFeatureFlag(flag),
1407
+ },
1408
+ ],
1409
+ }),
1410
+ )
1411
+ : html`<p className="admin-item-meta">Nenhuma feature flag disponível.</p>`}
1412
+ </div>
1413
+ </section>
1414
+
1415
+ <section className="section section-security span-6" hidden=${activePage !== 'configuracoes'}>
1416
+ <div className="section-head">
1417
+ <div>
1418
+ <h4 className="panel-title">Bans</h4>
1419
+ <p className="panel-subtitle">Estado das contas bloqueadas.</p>
1420
+ </div>
1421
+ </div>
1422
+
1423
+ <div className="admin-list">
1424
+ ${blockedAccounts.length
1425
+ ? blockedAccounts.map((ban) => {
1426
+ const activeBan = !ban?.revoked_at;
1427
+ return renderListItem({
1428
+ title: normalizeString(ban?.email || ban?.owner_jid || ban?.google_sub) || 'Conta bloqueada',
1429
+ severity: activeBan ? 'critical' : 'low',
1430
+ badgeLabel: activeBan ? 'ATIVO' : 'REVOGADO',
1431
+ meta: [normalizeString(ban?.reason) ? `Motivo: ${normalizeString(ban?.reason)}` : '', `Criado: ${formatDateTime(ban?.created_at)}`, ban?.revoked_at ? `Revogado: ${formatDateTime(ban?.revoked_at)}` : ''].filter(Boolean),
1432
+ actions: activeBan
1433
+ ? [
1434
+ {
1435
+ label: 'Revogar ban',
1436
+ onClick: () => handleRevokeBan(ban?.id),
1437
+ },
1438
+ ]
1439
+ : [],
1440
+ });
1441
+ })
1442
+ : html`<p className="admin-item-meta">Nenhum ban registrado.</p>`}
1443
+ </div>
1444
+ </section>
1445
+
1446
+ <section className="section section-governance span-12" hidden=${activePage !== 'configuracoes'}>
1447
+ <div className="section-head">
1448
+ <div>
1449
+ <h4 className="panel-title">Atalhos Operacionais</h4>
1450
+ <p className="panel-subtitle">Ações de manutenção com confirmação para operações críticas.</p>
1451
+ </div>
1452
+ </div>
1453
+ <div className="admin-item-actions">${operationalShortcuts.map((shortcut) => html`<button key=${shortcut.action} type="button" className="admin-mini-btn" disabled=${busy} onClick=${() => handleRunOp(shortcut.action, shortcut.label)}>${shortcut.label}</button>`)}</div>
1454
+ </section>
1455
+
1456
+ <section id="exportacao" className="section section-governance span-12" hidden=${activePage !== 'exportacao'}>
1457
+ <div className="section-head">
1458
+ <div>
1459
+ <h4 className="panel-title">Exportação</h4>
1460
+ <p className="panel-subtitle">Exportar métricas e eventos para auditoria externa.</p>
1461
+ </div>
1462
+ </div>
1463
+ <div className="admin-item-actions">
1464
+ <button type="button" className="admin-mini-btn" disabled=${busy} onClick=${() => handleExport('metrics', 'json')}>Métricas JSON</button>
1465
+ <button type="button" className="admin-mini-btn" disabled=${busy} onClick=${() => handleExport('metrics', 'csv')}>Métricas CSV</button>
1466
+ <button type="button" className="admin-mini-btn" disabled=${busy} onClick=${() => handleExport('events', 'json')}>Eventos JSON</button>
1467
+ <button type="button" className="admin-mini-btn" disabled=${busy} onClick=${() => handleExport('events', 'csv')}>Eventos CSV</button>
1468
+ </div>
1469
+ </section>
1470
+ </div>
1471
+
1472
+ <div className="admin-actions">
1473
+ <button type="button" className="btn" disabled=${busy} onClick=${refreshPanel}>Atualizar dados admin</button>
1474
+ <button type="button" className="btn" disabled=${busy} onClick=${handleAdminLogout}>Sair do admin</button>
1475
+ </div>
1476
+ `
1477
+ : null}
1478
+ </section>
1479
+
1480
+ <p className="footer">Omnizap · ${new Date().getFullYear()}</p>
1481
+ </div>
1482
+ </div>
1483
+ </main>
1484
+
1485
+ <div className="toast-stack" aria-live="polite" aria-atomic="false">
1486
+ ${toasts.map(
1487
+ (toast) => html`
1488
+ <article key=${toast.id} className=${`toast ${toast.kind}`}>
1489
+ <strong>${toast.title}</strong>
1490
+ <p>${toast.message}</p>
1491
+ </article>
1492
+ `,
1493
+ )}
1494
+ </div>
1495
+ `;
1496
+ };
1497
+
1498
+ const rootElement = document.getElementById('user-systemadm-react-root');
1499
+ if (rootElement) {
1500
+ const config = {
1501
+ apiBasePath: normalizeBasePath(rootElement.dataset.apiBasePath, DEFAULT_API_BASE_PATH),
1502
+ loginPath: normalizeBasePath(rootElement.dataset.loginPath, DEFAULT_LOGIN_PATH),
1503
+ stickersPath: normalizeBasePath(rootElement.dataset.stickersPath, DEFAULT_STICKERS_PATH),
1504
+ };
1505
+ createRoot(rootElement).render(html`<${UserSystemAdmReactApp} config=${config} />`);
1506
+ }