@kaikybrofc/omnizap-system 2.3.0 → 2.3.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 (47) hide show
  1. package/README.md +13 -13
  2. package/app/controllers/messageController.js +473 -255
  3. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
  4. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +25 -5
  5. package/app/observability/metrics.js +6 -3
  6. package/app/services/googleWebLinkService.js +77 -0
  7. package/database/index.js +3 -0
  8. package/database/migrations/20260228_0027_web_visit_event.sql +15 -0
  9. package/database/migrations/20260301_0028_message_analysis_event.sql +32 -0
  10. package/database/migrations/20260301_0029_admin_action_audit.sql +16 -0
  11. package/package.json +1 -1
  12. package/public/index.html +53 -59
  13. package/public/js/apps/homeApp.js +259 -61
  14. package/public/js/apps/loginApp.js +184 -29
  15. package/public/js/apps/stickersAdminApp.js +3 -9
  16. package/public/js/apps/stickersApp.js +0 -28
  17. package/public/js/apps/userApp.js +1160 -14
  18. package/public/js/apps/userProfileApp.js +244 -0
  19. package/public/licenca/index.html +98 -2
  20. package/public/login/index.html +430 -100
  21. package/public/termos-de-uso/index.html +245 -25
  22. package/public/user/index.html +3 -1
  23. package/public/user/systemadm/index.html +774 -0
  24. package/server/auth/googleWebAuth/googleWebAuthService.js +614 -0
  25. package/server/controllers/stickerCatalog/nonCatalogHandlers.js +208 -0
  26. package/server/controllers/stickerCatalogController.js +1350 -924
  27. package/server/controllers/systemAdminController.js +141 -0
  28. package/server/controllers/userController.js +87 -0
  29. package/server/http/httpServer.js +72 -32
  30. package/server/middleware/cachePolicy.js +24 -0
  31. package/server/middleware/cachePolicyHelpers.js +2 -0
  32. package/server/middleware/rateLimit.js +82 -0
  33. package/server/middleware/requestLogger.js +16 -0
  34. package/server/middleware/requireAdminAuth.js +42 -0
  35. package/server/middleware/securityHeaders.js +6 -0
  36. package/server/routes/admin/systemAdminRouter.js +56 -0
  37. package/server/routes/health/healthRouter.js +41 -0
  38. package/server/routes/indexRouter.js +203 -0
  39. package/server/routes/metrics/metricsRouter.js +13 -0
  40. package/server/routes/stickerCatalog/catalogHandlers/catalogAdminHttp.js +44 -0
  41. package/server/routes/stickerCatalog/stickerApiRouter.js +84 -0
  42. package/server/routes/stickerCatalog/stickerDataRouter.js +140 -0
  43. package/server/routes/stickerCatalog/stickerSiteRouter.js +43 -0
  44. package/server/routes/user/userRouter.js +56 -0
  45. package/server/utils/safePath.js +26 -0
  46. package/server/routes/metricsRoute.js +0 -7
  47. package/server/routes/stickerCatalogRoute.js +0 -20
@@ -1,4 +1,4 @@
1
- /* global document, window, fetch, URLSearchParams */
1
+ /* global document, window, fetch, URL, URLSearchParams, Blob, Element */
2
2
 
3
3
  const DEFAULT_API_BASE_PATH = '/api/sticker-packs';
4
4
  const DEFAULT_STICKERS_PATH = '/stickers';
@@ -31,6 +31,57 @@ if (root) {
31
31
  manageHeadLink: document.getElementById('user-manage-head-link'),
32
32
  manageMainLink: document.getElementById('user-manage-main-link'),
33
33
  currentYear: document.getElementById('user-current-year'),
34
+
35
+ adminPanel: document.getElementById('user-admin-panel'),
36
+ adminRole: document.getElementById('user-admin-role'),
37
+ adminStatus: document.getElementById('user-admin-status'),
38
+ adminError: document.getElementById('user-admin-error'),
39
+ adminUnlockForm: document.getElementById('user-admin-unlock-form'),
40
+ adminPassword: document.getElementById('user-admin-password'),
41
+ adminUnlockBtn: document.getElementById('user-admin-unlock-btn'),
42
+ adminOverview: document.getElementById('user-admin-overview'),
43
+ adminRefreshBtn: document.getElementById('user-admin-refresh-btn'),
44
+ adminLogoutBtn: document.getElementById('user-admin-logout-btn'),
45
+
46
+ adminBotsOnline: document.getElementById('user-admin-bots-online'),
47
+ adminMessagesToday: document.getElementById('user-admin-messages-today'),
48
+ adminSpamBlocked: document.getElementById('user-admin-spam-blocked'),
49
+ adminUptime: document.getElementById('user-admin-uptime'),
50
+ adminErrors5xx: document.getElementById('user-admin-errors-5xx'),
51
+ adminTotalPacks: document.getElementById('user-admin-total-packs'),
52
+ adminTotalStickers: document.getElementById('user-admin-total-stickers'),
53
+ adminActiveBans: document.getElementById('user-admin-active-bans'),
54
+ adminKnownUsers: document.getElementById('user-admin-known-users'),
55
+ adminActiveSessions: document.getElementById('user-admin-active-sessions'),
56
+ adminVisits24h: document.getElementById('user-admin-visits-24h'),
57
+ adminVisits7d: document.getElementById('user-admin-visits-7d'),
58
+ adminUniqueVisitors7d: document.getElementById('user-admin-unique-visitors-7d'),
59
+
60
+ adminHealthCpu: document.getElementById('user-admin-health-cpu'),
61
+ adminHealthRam: document.getElementById('user-admin-health-ram'),
62
+ adminHealthLatency: document.getElementById('user-admin-health-latency'),
63
+ adminHealthQueue: document.getElementById('user-admin-health-queue'),
64
+ adminHealthDb: document.getElementById('user-admin-health-db'),
65
+
66
+ adminModerationList: document.getElementById('user-admin-moderation-list'),
67
+ adminSessionsList: document.getElementById('user-admin-sessions-list'),
68
+ adminUsersList: document.getElementById('user-admin-users-list'),
69
+ adminBansList: document.getElementById('user-admin-bans-list'),
70
+ adminAuditList: document.getElementById('user-admin-audit-list'),
71
+ adminFlagsList: document.getElementById('user-admin-flags-list'),
72
+ adminAlertsList: document.getElementById('user-admin-alerts-list'),
73
+ adminOpsStatus: document.getElementById('user-admin-ops-status'),
74
+
75
+ adminSearchForm: document.getElementById('user-admin-search-form'),
76
+ adminSearchInput: document.getElementById('user-admin-search-input'),
77
+ adminSearchBtn: document.getElementById('user-admin-search-btn'),
78
+ adminSearchResults: document.getElementById('user-admin-search-results'),
79
+
80
+ adminExportMetricsJsonBtn: document.getElementById('user-admin-export-metrics-json'),
81
+ adminExportMetricsCsvBtn: document.getElementById('user-admin-export-metrics-csv'),
82
+ adminExportEventsJsonBtn: document.getElementById('user-admin-export-events-json'),
83
+ adminExportEventsCsvBtn: document.getElementById('user-admin-export-events-csv'),
84
+ adminOpButtons: Array.from(document.querySelectorAll('[data-admin-op-action]')),
34
85
  };
35
86
 
36
87
  const state = {
@@ -38,17 +89,35 @@ if (root) {
38
89
  stickersPath: String(root.dataset.stickersPath || DEFAULT_STICKERS_PATH).trim() || DEFAULT_STICKERS_PATH,
39
90
  loginPath: String(root.dataset.loginPath || DEFAULT_LOGIN_PATH).trim() || DEFAULT_LOGIN_PATH,
40
91
  botPhone: '',
92
+ adminBusy: false,
93
+ adminStatusPayload: null,
94
+ adminOverviewPayload: null,
95
+ adminSearchPayload: null,
96
+ adminOpsMessage: '',
41
97
  };
42
98
 
43
99
  const sessionApiPath = `${state.apiBasePath}/auth/google/session`;
44
100
  const myProfileApiPath = `${state.apiBasePath}/me`;
45
101
  const botContactApiPath = `${state.apiBasePath}/bot-contact`;
102
+ const adminSessionApiPath = `${state.apiBasePath}/admin/session`;
103
+ const adminOverviewApiPath = `${state.apiBasePath}/admin/overview`;
104
+ const adminForceLogoutApiPath = `${state.apiBasePath}/admin/users/force-logout`;
105
+ const adminFeatureFlagsApiPath = `${state.apiBasePath}/admin/feature-flags`;
106
+ const adminOpsApiPath = `${state.apiBasePath}/admin/ops`;
107
+ const adminSearchApiPath = `${state.apiBasePath}/admin/search`;
108
+ const adminExportApiPath = `${state.apiBasePath}/admin/export`;
109
+ const adminBansApiPath = `${state.apiBasePath}/admin/bans`;
46
110
 
47
111
  const setText = (el, value) => {
48
112
  if (!el) return;
49
113
  el.textContent = String(value || '');
50
114
  };
51
115
 
116
+ const clearNode = (el) => {
117
+ if (!el) return;
118
+ while (el.firstChild) el.removeChild(el.firstChild);
119
+ };
120
+
52
121
  const showError = (message) => {
53
122
  if (!ui.error) return;
54
123
  const safeMessage = String(message || '').trim();
@@ -56,7 +125,16 @@ if (root) {
56
125
  if (safeMessage) ui.error.textContent = safeMessage;
57
126
  };
58
127
 
128
+ const showAdminError = (message) => {
129
+ if (!ui.adminError) return;
130
+ const safeMessage = String(message || '').trim();
131
+ ui.adminError.hidden = !safeMessage;
132
+ if (safeMessage) ui.adminError.textContent = safeMessage;
133
+ };
134
+
59
135
  const normalizeDigits = (value) => String(value || '').replace(/\D+/g, '');
136
+ const normalizeString = (value) => String(value || '').trim();
137
+ const isObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
60
138
 
61
139
  const formatPhone = (digits) => {
62
140
  const value = normalizeDigits(digits);
@@ -76,9 +154,28 @@ if (root) {
76
154
  return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(ms));
77
155
  };
78
156
 
157
+ const formatPercent = (value) => {
158
+ const numeric = Number(value);
159
+ if (!Number.isFinite(numeric)) return 'n/d';
160
+ return `${numeric.toFixed(1)}%`;
161
+ };
162
+
163
+ const formatMilliseconds = (value) => {
164
+ const numeric = Number(value);
165
+ if (!Number.isFinite(numeric)) return 'n/d';
166
+ return `${Math.round(numeric)} ms`;
167
+ };
168
+
169
+ const formatIntegerOrNd = (value) => {
170
+ const numeric = Number(value);
171
+ if (!Number.isFinite(numeric)) return 'n/d';
172
+ return formatNumber(numeric);
173
+ };
174
+
79
175
  const buildLoginRedirectUrl = () => {
80
176
  const loginUrl = new URL(state.loginPath, window.location.origin);
81
- loginUrl.searchParams.set('next', '/user/');
177
+ const nextPath = `${window.location.pathname || '/user/systemadm/'}${window.location.search || ''}`;
178
+ loginUrl.searchParams.set('next', nextPath);
82
179
  return `${loginUrl.pathname}${loginUrl.search}`;
83
180
  };
84
181
 
@@ -98,6 +195,7 @@ if (root) {
98
195
  credentials: 'include',
99
196
  ...init,
100
197
  });
198
+
101
199
  let payload = null;
102
200
  try {
103
201
  payload = await response.json();
@@ -113,24 +211,190 @@ if (root) {
113
211
  return payload || {};
114
212
  };
115
213
 
214
+ const fetchWithAuth = async (url, init = {}) => {
215
+ const response = await fetch(url, {
216
+ credentials: 'include',
217
+ ...init,
218
+ });
219
+ if (!response.ok) {
220
+ let message = `Falha HTTP ${response.status}`;
221
+ try {
222
+ const payload = await response.json();
223
+ if (payload?.error) message = payload.error;
224
+ } catch {
225
+ // noop
226
+ }
227
+ const error = new Error(message);
228
+ error.statusCode = response.status;
229
+ throw error;
230
+ }
231
+ return response;
232
+ };
233
+
116
234
  const redirectToLogin = () => {
117
235
  window.location.assign(buildLoginRedirectUrl());
118
236
  };
119
237
 
120
- const loadBotPhone = async () => {
121
- try {
122
- const payload = await fetchJson(botContactApiPath, { method: 'GET' });
123
- state.botPhone = normalizeDigits(payload?.data?.phone || '');
124
- } catch {
125
- state.botPhone = '';
238
+ const normalizeOwnerJidCandidate = (value) => {
239
+ const jid = normalizeString(value);
240
+ if (!jid || !jid.includes('@')) return '';
241
+ if (jid.endsWith('@g.us')) return '';
242
+ return jid;
243
+ };
244
+
245
+ const compactIdentityPayload = (raw = {}) => {
246
+ const payload = {};
247
+ const sessionToken = normalizeString(raw.session_token);
248
+ const googleSub = normalizeString(raw.google_sub);
249
+ const email = normalizeString(raw.email);
250
+ const ownerJid = normalizeOwnerJidCandidate(raw.owner_jid);
251
+ if (sessionToken) payload.session_token = sessionToken;
252
+ if (googleSub) payload.google_sub = googleSub;
253
+ if (email) payload.email = email;
254
+ if (ownerJid) payload.owner_jid = ownerJid;
255
+ return payload;
256
+ };
257
+
258
+ const buildIdentityLabel = (identity = {}) => {
259
+ const email = normalizeString(identity.email);
260
+ const ownerJid = normalizeString(identity.owner_jid);
261
+ const googleSub = normalizeString(identity.google_sub);
262
+ const sessionToken = normalizeString(identity.session_token);
263
+ if (email) return email;
264
+ if (ownerJid) return ownerJid;
265
+ if (googleSub) return googleSub;
266
+ if (sessionToken) return `${sessionToken.slice(0, 8)}...`;
267
+ return 'identidade';
268
+ };
269
+
270
+ const getAdminSession = () => state.adminStatusPayload?.session || null;
271
+ const isAdminAuthenticated = () => Boolean(getAdminSession()?.authenticated);
272
+ const isAdminEligible = () => Boolean(state.adminStatusPayload?.eligible_google_login || isAdminAuthenticated());
273
+
274
+ const resolveAdminRole = () =>
275
+ String(getAdminSession()?.role || state.adminStatusPayload?.eligible_role || '')
276
+ .trim()
277
+ .toLowerCase();
278
+
279
+ const formatAdminRole = (role) => {
280
+ if (role === 'owner') return 'dono';
281
+ if (role === 'moderator') return 'moderador';
282
+ return 'admin';
283
+ };
284
+
285
+ const createItemMeta = (text) => {
286
+ const p = document.createElement('p');
287
+ p.className = 'admin-item-meta';
288
+ p.textContent = text;
289
+ return p;
290
+ };
291
+
292
+ const createBadge = (label, severity = 'low') => {
293
+ const normalizedSeverity = ['critical', 'high', 'medium', 'low'].includes(String(severity)) ? String(severity) : 'low';
294
+ const badge = document.createElement('span');
295
+ badge.className = `admin-badge ${normalizedSeverity}`;
296
+ badge.textContent = String(label || '').trim() || normalizedSeverity.toUpperCase();
297
+ return badge;
298
+ };
299
+
300
+ const createMiniButton = (label, onClick) => {
301
+ const button = document.createElement('button');
302
+ button.type = 'button';
303
+ button.className = 'admin-mini-btn';
304
+ button.textContent = label;
305
+ button.addEventListener('click', () => {
306
+ if (typeof onClick === 'function') void onClick();
307
+ });
308
+ return button;
309
+ };
310
+
311
+ const createMiniLink = (label, href) => {
312
+ const link = document.createElement('a');
313
+ link.className = 'admin-mini-btn';
314
+ link.textContent = label;
315
+ link.href = href;
316
+ link.target = '_blank';
317
+ link.rel = 'noreferrer noopener';
318
+ return link;
319
+ };
320
+
321
+ const renderListPlaceholder = (container, message) => {
322
+ if (!container) return;
323
+ clearNode(container);
324
+ container.appendChild(createItemMeta(message));
325
+ };
326
+
327
+ const appendListItem = ({ container, title, severity = '', badgeLabel = '', meta = [], actions = [], customNode = null }) => {
328
+ if (!container) return;
329
+
330
+ const item = document.createElement('article');
331
+ item.className = 'admin-item';
332
+
333
+ const titleEl = document.createElement('p');
334
+ titleEl.className = 'admin-item-title';
335
+ titleEl.textContent = title;
336
+ item.appendChild(titleEl);
337
+
338
+ if (badgeLabel) {
339
+ item.appendChild(createBadge(badgeLabel, severity));
126
340
  }
127
- if (ui.chatLink) ui.chatLink.href = buildWhatsAppMenuUrl(state.botPhone);
341
+
342
+ for (const line of meta) {
343
+ const text = normalizeString(line);
344
+ if (!text) continue;
345
+ item.appendChild(createItemMeta(text));
346
+ }
347
+
348
+ if (customNode) item.appendChild(customNode);
349
+
350
+ if (Array.isArray(actions) && actions.length) {
351
+ const actionsWrap = document.createElement('div');
352
+ actionsWrap.className = 'admin-item-actions';
353
+ for (const actionNode of actions) {
354
+ if (actionNode instanceof Element) actionsWrap.appendChild(actionNode);
355
+ }
356
+ if (actionsWrap.childNodes.length > 0) {
357
+ item.appendChild(actionsWrap);
358
+ }
359
+ }
360
+
361
+ container.appendChild(item);
362
+ };
363
+
364
+ const setAdminBusy = (value) => {
365
+ const busy = Boolean(value);
366
+ state.adminBusy = busy;
367
+
368
+ const authenticated = isAdminAuthenticated();
369
+ const eligible = isAdminEligible();
370
+
371
+ if (ui.adminPanel) {
372
+ const controls = ui.adminPanel.querySelectorAll('button, input, select, textarea');
373
+ for (const control of controls) {
374
+ const inUnlockForm = Boolean(control.closest('#user-admin-unlock-form'));
375
+ const inOverview = Boolean(control.closest('#user-admin-overview'));
376
+ if (inUnlockForm) {
377
+ control.disabled = busy || !eligible || authenticated;
378
+ continue;
379
+ }
380
+ if (inOverview) {
381
+ control.disabled = busy || !authenticated;
382
+ continue;
383
+ }
384
+ control.disabled = busy;
385
+ }
386
+ }
387
+
388
+ if (ui.adminPassword) ui.adminPassword.disabled = busy || !isAdminEligible() || isAdminAuthenticated();
389
+ if (ui.adminUnlockBtn) ui.adminUnlockBtn.disabled = busy || !isAdminEligible() || isAdminAuthenticated();
390
+ if (ui.adminRefreshBtn) ui.adminRefreshBtn.disabled = busy || !isAdminAuthenticated();
391
+ if (ui.adminLogoutBtn) ui.adminLogoutBtn.disabled = busy || !isAdminAuthenticated();
128
392
  };
129
393
 
130
394
  const renderSession = (sessionData) => {
131
395
  const user = sessionData?.user || {};
132
- const ownerPhone = String(sessionData?.owner_phone || '').trim();
133
- const ownerJid = String(sessionData?.owner_jid || '').trim();
396
+ const ownerPhone = normalizeString(sessionData?.owner_phone);
397
+ const ownerJid = normalizeString(sessionData?.owner_jid);
134
398
 
135
399
  setText(ui.name, user?.name || 'Conta Google');
136
400
  setText(ui.email, user?.email || 'Email não disponível');
@@ -143,7 +407,7 @@ if (root) {
143
407
  }
144
408
 
145
409
  if (ui.avatar) {
146
- const picture = String(user?.picture || '').trim() || FALLBACK_AVATAR;
410
+ const picture = normalizeString(user?.picture) || FALLBACK_AVATAR;
147
411
  ui.avatar.src = picture;
148
412
  ui.avatar.onerror = () => {
149
413
  ui.avatar.src = FALLBACK_AVATAR;
@@ -151,7 +415,7 @@ if (root) {
151
415
  }
152
416
 
153
417
  setText(ui.ownerJid, ownerJid || 'n/d');
154
- setText(ui.googleSub, String(user?.sub || '').trim() || 'n/d');
418
+ setText(ui.googleSub, normalizeString(user?.sub) || 'n/d');
155
419
  setText(ui.expiresAt, formatDateTime(sessionData?.expires_at));
156
420
 
157
421
  if (ui.profile) ui.profile.hidden = false;
@@ -162,7 +426,7 @@ if (root) {
162
426
  const renderPackMetrics = (payload) => {
163
427
  const data = payload?.data || {};
164
428
  const packs = Array.isArray(data?.packs) ? data.packs : [];
165
- const stats = data?.stats && typeof data.stats === 'object' ? data.stats : {};
429
+ const stats = isObject(data?.stats) ? data.stats : {};
166
430
 
167
431
  let stickers = 0;
168
432
  let downloads = 0;
@@ -181,6 +445,829 @@ if (root) {
181
445
  if (ui.grid) ui.grid.hidden = false;
182
446
  };
183
447
 
448
+ const setAdminMetricsDefaults = () => {
449
+ setText(ui.adminBotsOnline, '0');
450
+ setText(ui.adminMessagesToday, 'n/d');
451
+ setText(ui.adminSpamBlocked, 'n/d');
452
+ setText(ui.adminUptime, 'n/d');
453
+ setText(ui.adminErrors5xx, '0');
454
+ setText(ui.adminTotalPacks, '0');
455
+ setText(ui.adminTotalStickers, '0');
456
+ setText(ui.adminActiveBans, '0');
457
+ setText(ui.adminKnownUsers, '0');
458
+ setText(ui.adminActiveSessions, '0');
459
+ setText(ui.adminVisits24h, '0');
460
+ setText(ui.adminVisits7d, '0');
461
+ setText(ui.adminUniqueVisitors7d, '0');
462
+
463
+ setText(ui.adminHealthCpu, 'n/d');
464
+ setText(ui.adminHealthRam, 'n/d');
465
+ setText(ui.adminHealthLatency, 'n/d');
466
+ setText(ui.adminHealthQueue, 'n/d');
467
+ setText(ui.adminHealthDb, 'n/d');
468
+
469
+ renderListPlaceholder(ui.adminModerationList, 'Nenhum evento recente de moderação.');
470
+ renderListPlaceholder(ui.adminSessionsList, 'Nenhuma sessão ativa encontrada.');
471
+ renderListPlaceholder(ui.adminUsersList, 'Nenhum usuário encontrado.');
472
+ renderListPlaceholder(ui.adminBansList, 'Nenhuma conta bloqueada.');
473
+ renderListPlaceholder(ui.adminAuditList, 'Sem eventos de auditoria recentes.');
474
+ renderListPlaceholder(ui.adminFlagsList, 'Nenhuma feature flag disponível.');
475
+ renderListPlaceholder(ui.adminAlertsList, 'Sem alertas ativos no momento.');
476
+ renderListPlaceholder(ui.adminSearchResults, 'Faça uma busca para ver usuários, grupos, packs e sessões.');
477
+ setText(ui.adminOpsStatus, state.adminOpsMessage || 'Ações operacionais disponíveis.');
478
+ };
479
+
480
+ const buildBanPayloadFromEvent = (event) => {
481
+ const metadata = isObject(event?.metadata) ? event.metadata : {};
482
+ const payload = compactIdentityPayload({
483
+ google_sub: metadata.google_sub,
484
+ email: metadata.email,
485
+ owner_jid: event?.sender_id || metadata.owner_jid,
486
+ });
487
+ if (!Object.keys(payload).length) return null;
488
+ payload.reason = `Ban via moderação (${normalizeString(event?.event_type) || 'evento'})`;
489
+ return payload;
490
+ };
491
+
492
+ const buildForceLogoutPayloadFromAny = (entry = {}) =>
493
+ compactIdentityPayload({
494
+ session_token: entry?.session_token,
495
+ google_sub: entry?.google_sub,
496
+ email: entry?.email,
497
+ owner_jid: entry?.owner_jid,
498
+ });
499
+
500
+ const handleAdminForceLogout = async (identity, contextLabel = '') => {
501
+ const payload = compactIdentityPayload(identity);
502
+ if (!Object.keys(payload).length) {
503
+ showAdminError('Não foi possível forçar logout: identidade ausente.');
504
+ return;
505
+ }
506
+ if (state.adminBusy) return;
507
+
508
+ showAdminError('');
509
+ setAdminBusy(true);
510
+ try {
511
+ const response = await fetchJson(adminForceLogoutApiPath, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
514
+ body: JSON.stringify(payload),
515
+ });
516
+ const removed = Number(response?.data?.removed_sessions || 0);
517
+ const label = normalizeString(contextLabel) || buildIdentityLabel(payload);
518
+ state.adminOpsMessage = `Logout forçado concluído para ${label}. Sessões removidas: ${removed}.`;
519
+ await refreshAdminArea({ keepCurrentError: true });
520
+ } catch (error) {
521
+ showAdminError(error?.message || 'Falha ao forçar logout.');
522
+ } finally {
523
+ setAdminBusy(false);
524
+ renderAdminPanel();
525
+ }
526
+ };
527
+
528
+ const handleAdminBanCreate = async (banPayload, contextLabel = '') => {
529
+ if (!isObject(banPayload)) return;
530
+ const payload = {
531
+ ...compactIdentityPayload(banPayload),
532
+ reason: normalizeString(banPayload.reason),
533
+ };
534
+ if (!payload.google_sub && !payload.email && !payload.owner_jid) {
535
+ showAdminError('Não foi possível banir: identidade ausente.');
536
+ return;
537
+ }
538
+ if (state.adminBusy) return;
539
+
540
+ showAdminError('');
541
+ setAdminBusy(true);
542
+ try {
543
+ const response = await fetchJson(adminBansApiPath, {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
546
+ body: JSON.stringify(payload),
547
+ });
548
+ const created = Boolean(response?.data?.created);
549
+ const label = normalizeString(contextLabel) || buildIdentityLabel(payload);
550
+ state.adminOpsMessage = created ? `Conta banida: ${label}.` : `Conta já estava banida: ${label}.`;
551
+ await refreshAdminArea({ keepCurrentError: true });
552
+ } catch (error) {
553
+ showAdminError(error?.message || 'Falha ao criar ban.');
554
+ } finally {
555
+ setAdminBusy(false);
556
+ renderAdminPanel();
557
+ }
558
+ };
559
+
560
+ const handleAdminBanRevoke = async (banId) => {
561
+ const normalizedId = normalizeString(banId);
562
+ if (!normalizedId || state.adminBusy) return;
563
+
564
+ showAdminError('');
565
+ setAdminBusy(true);
566
+ try {
567
+ await fetchJson(`${adminBansApiPath}/${encodeURIComponent(normalizedId)}/revoke`, { method: 'DELETE' });
568
+ state.adminOpsMessage = `Ban ${normalizedId} revogado com sucesso.`;
569
+ await refreshAdminArea({ keepCurrentError: true });
570
+ } catch (error) {
571
+ showAdminError(error?.message || 'Falha ao revogar ban.');
572
+ } finally {
573
+ setAdminBusy(false);
574
+ renderAdminPanel();
575
+ }
576
+ };
577
+
578
+ const handleAdminFeatureFlagUpdate = async ({ flagName = '', isEnabled = false, rolloutPercent = 100, description = '' } = {}) => {
579
+ const normalizedName = normalizeString(flagName);
580
+ if (!normalizedName || state.adminBusy) return;
581
+
582
+ showAdminError('');
583
+ setAdminBusy(true);
584
+ try {
585
+ await fetchJson(adminFeatureFlagsApiPath, {
586
+ method: 'POST',
587
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
588
+ body: JSON.stringify({
589
+ flag_name: normalizedName,
590
+ is_enabled: Boolean(isEnabled),
591
+ rollout_percent: Math.max(0, Math.min(100, Math.floor(Number(rolloutPercent) || 0))),
592
+ description: normalizeString(description),
593
+ }),
594
+ });
595
+ state.adminOpsMessage = `Feature flag ${normalizedName} atualizada.`;
596
+ await refreshAdminArea({ keepCurrentError: true });
597
+ } catch (error) {
598
+ showAdminError(error?.message || 'Falha ao atualizar feature flag.');
599
+ } finally {
600
+ setAdminBusy(false);
601
+ renderAdminPanel();
602
+ }
603
+ };
604
+
605
+ const handleAdminOpsAction = async (action) => {
606
+ const normalizedAction = normalizeString(action);
607
+ if (!normalizedAction || state.adminBusy) return;
608
+
609
+ showAdminError('');
610
+ setAdminBusy(true);
611
+ try {
612
+ const response = await fetchJson(adminOpsApiPath, {
613
+ method: 'POST',
614
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
615
+ body: JSON.stringify({ action: normalizedAction }),
616
+ });
617
+ const message = normalizeString(response?.data?.message) || `Ação ${normalizedAction} concluída.`;
618
+ state.adminOpsMessage = `${message} (${formatDateTime(response?.data?.updated_at)})`;
619
+ setText(ui.adminOpsStatus, state.adminOpsMessage);
620
+ await refreshAdminArea({ keepCurrentError: true });
621
+ } catch (error) {
622
+ showAdminError(error?.message || 'Falha ao executar ação operacional.');
623
+ } finally {
624
+ setAdminBusy(false);
625
+ renderAdminPanel();
626
+ }
627
+ };
628
+
629
+ const renderModerationQueue = (events) => {
630
+ const list = Array.isArray(events) ? events : [];
631
+ clearNode(ui.adminModerationList);
632
+
633
+ if (!list.length) {
634
+ renderListPlaceholder(ui.adminModerationList, 'Nenhum evento recente de moderação.');
635
+ return;
636
+ }
637
+
638
+ for (const event of list) {
639
+ const title = normalizeString(event?.title) || 'Evento de moderação';
640
+ const severity = normalizeString(event?.severity) || 'low';
641
+ const badgeLabel = severity.toUpperCase();
642
+ const createdAt = formatDateTime(event?.created_at || event?.revoked_at);
643
+ const meta = [
644
+ normalizeString(event?.subtitle),
645
+ `Tipo: ${normalizeString(event?.event_type) || 'evento'} • ${createdAt}`,
646
+ ];
647
+
648
+ if (normalizeString(event?.reason)) meta.push(`Motivo: ${normalizeString(event.reason)}`);
649
+
650
+ const actions = [];
651
+ if (event?.event_type === 'ban') {
652
+ if (event?.ban_id && !event?.revoked_at) {
653
+ actions.push(createMiniButton('Revogar ban', () => handleAdminBanRevoke(event.ban_id)));
654
+ }
655
+ } else {
656
+ const banPayload = buildBanPayloadFromEvent(event);
657
+ if (banPayload) {
658
+ actions.push(createMiniButton('Banir conta', () => handleAdminBanCreate(banPayload, buildIdentityLabel(banPayload))));
659
+ }
660
+ const logoutPayload = compactIdentityPayload({
661
+ session_token: event?.metadata?.session_token,
662
+ google_sub: event?.metadata?.google_sub,
663
+ email: event?.metadata?.email,
664
+ owner_jid: event?.sender_id || event?.metadata?.owner_jid,
665
+ });
666
+ if (Object.keys(logoutPayload).length) {
667
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(logoutPayload, buildIdentityLabel(logoutPayload))));
668
+ }
669
+ }
670
+
671
+ appendListItem({
672
+ container: ui.adminModerationList,
673
+ title,
674
+ severity,
675
+ badgeLabel,
676
+ meta,
677
+ actions,
678
+ });
679
+ }
680
+ };
681
+
682
+ const renderActiveSessions = (sessions) => {
683
+ const list = Array.isArray(sessions) ? sessions : [];
684
+ clearNode(ui.adminSessionsList);
685
+
686
+ if (!list.length) {
687
+ renderListPlaceholder(ui.adminSessionsList, 'Nenhuma sessão ativa encontrada.');
688
+ return;
689
+ }
690
+
691
+ for (const session of list) {
692
+ const identity = compactIdentityPayload(session);
693
+ const title = normalizeString(session?.name || session?.email || session?.owner_jid || 'Sessão ativa');
694
+ const meta = [
695
+ `Email: ${normalizeString(session?.email) || 'n/d'}`,
696
+ `Owner: ${normalizeString(session?.owner_jid) || 'n/d'}`,
697
+ `Último acesso: ${formatDateTime(session?.last_seen_at)} • Expira: ${formatDateTime(session?.expires_at)}`,
698
+ ];
699
+ const actions = [];
700
+ if (Object.keys(identity).length) {
701
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
702
+ }
703
+ appendListItem({
704
+ container: ui.adminSessionsList,
705
+ title,
706
+ severity: 'low',
707
+ badgeLabel: 'ATIVA',
708
+ meta,
709
+ actions,
710
+ });
711
+ }
712
+ };
713
+
714
+ const renderKnownUsers = (users) => {
715
+ const list = Array.isArray(users) ? users : [];
716
+ clearNode(ui.adminUsersList);
717
+
718
+ if (!list.length) {
719
+ renderListPlaceholder(ui.adminUsersList, 'Nenhum usuário encontrado.');
720
+ return;
721
+ }
722
+
723
+ for (const user of list) {
724
+ const identity = buildForceLogoutPayloadFromAny(user);
725
+ const title = normalizeString(user?.name || user?.email || user?.owner_jid || 'Usuário');
726
+ const meta = [
727
+ `Email: ${normalizeString(user?.email) || 'n/d'}`,
728
+ `Owner: ${normalizeString(user?.owner_jid) || 'n/d'}`,
729
+ `Último login: ${formatDateTime(user?.last_login_at)} • Último acesso: ${formatDateTime(user?.last_seen_at)}`,
730
+ ];
731
+ const actions = [];
732
+ if (Object.keys(identity).length) {
733
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
734
+ }
735
+ appendListItem({
736
+ container: ui.adminUsersList,
737
+ title,
738
+ severity: 'low',
739
+ badgeLabel: 'USER',
740
+ meta,
741
+ actions,
742
+ });
743
+ }
744
+ };
745
+
746
+ const renderBlockedAccounts = (bans) => {
747
+ const list = Array.isArray(bans) ? bans : [];
748
+ clearNode(ui.adminBansList);
749
+
750
+ if (!list.length) {
751
+ renderListPlaceholder(ui.adminBansList, 'Nenhuma conta bloqueada.');
752
+ return;
753
+ }
754
+
755
+ for (const ban of list) {
756
+ const identity = normalizeString(ban?.email || ban?.owner_jid || ban?.google_sub || `ban:${ban?.id || ''}`);
757
+ const isRevoked = Boolean(ban?.revoked_at);
758
+ const meta = [
759
+ `Criado: ${formatDateTime(ban?.created_at)}${isRevoked ? ` • Revogado: ${formatDateTime(ban?.revoked_at)}` : ''}`,
760
+ `Motivo: ${normalizeString(ban?.reason) || 'não informado'}`,
761
+ ];
762
+ const actions = [];
763
+ if (!isRevoked && normalizeString(ban?.id)) {
764
+ actions.push(createMiniButton('Revogar ban', () => handleAdminBanRevoke(ban.id)));
765
+ }
766
+ appendListItem({
767
+ container: ui.adminBansList,
768
+ title: identity,
769
+ severity: isRevoked ? 'low' : 'critical',
770
+ badgeLabel: isRevoked ? 'REVOGADO' : 'BLOQUEADO',
771
+ meta,
772
+ actions,
773
+ });
774
+ }
775
+ };
776
+
777
+ const renderAuditLog = (events) => {
778
+ const list = Array.isArray(events) ? events : [];
779
+ clearNode(ui.adminAuditList);
780
+
781
+ if (!list.length) {
782
+ renderListPlaceholder(ui.adminAuditList, 'Sem eventos de auditoria recentes.');
783
+ return;
784
+ }
785
+
786
+ for (const item of list.slice(0, 80)) {
787
+ const action = normalizeString(item?.action || 'action');
788
+ const targetType = normalizeString(item?.target_type || 'target');
789
+ const targetId = normalizeString(item?.target_id || '');
790
+ const status = normalizeString(item?.status || 'success');
791
+ const details = isObject(item?.details) ? Object.entries(item.details).slice(0, 3) : [];
792
+ const detailLine = details.length ? `Detalhes: ${details.map(([key, value]) => `${key}=${value}`).join(' • ')}` : '';
793
+
794
+ const meta = [
795
+ `Admin: ${normalizeString(item?.admin_email || item?.admin_google_sub || item?.admin_owner_jid || 'n/d')} (${formatAdminRole(normalizeString(item?.admin_role || 'admin'))})`,
796
+ `Alvo: ${targetType}${targetId ? ` / ${targetId}` : ''} • Em: ${formatDateTime(item?.created_at)}`,
797
+ ];
798
+ if (detailLine) meta.push(detailLine);
799
+
800
+ appendListItem({
801
+ container: ui.adminAuditList,
802
+ title: action,
803
+ severity: status === 'success' ? 'low' : 'high',
804
+ badgeLabel: status.toUpperCase(),
805
+ meta,
806
+ });
807
+ }
808
+ };
809
+
810
+ const renderFeatureFlags = (flags) => {
811
+ const list = Array.isArray(flags) ? flags : [];
812
+ clearNode(ui.adminFlagsList);
813
+
814
+ if (!list.length) {
815
+ renderListPlaceholder(ui.adminFlagsList, 'Nenhuma feature flag disponível.');
816
+ return;
817
+ }
818
+
819
+ for (const flag of list) {
820
+ const flagName = normalizeString(flag?.flag_name);
821
+ const isEnabled = Boolean(flag?.is_enabled);
822
+ const rollout = Math.max(0, Math.min(100, Math.floor(Number(flag?.rollout_percent) || 0)));
823
+ const description = normalizeString(flag?.description);
824
+ const updatedBy = normalizeString(flag?.updated_by);
825
+
826
+ const rolloutForm = document.createElement('form');
827
+ rolloutForm.className = 'admin-inline-form';
828
+
829
+ const rolloutInput = document.createElement('input');
830
+ rolloutInput.className = 'admin-input';
831
+ rolloutInput.type = 'number';
832
+ rolloutInput.min = '0';
833
+ rolloutInput.max = '100';
834
+ rolloutInput.step = '1';
835
+ rolloutInput.value = String(rollout);
836
+ rolloutInput.setAttribute('aria-label', `Rollout de ${flagName}`);
837
+
838
+ const rolloutBtn = document.createElement('button');
839
+ rolloutBtn.type = 'submit';
840
+ rolloutBtn.className = 'admin-mini-btn';
841
+ rolloutBtn.textContent = 'Salvar rollout';
842
+
843
+ rolloutForm.appendChild(rolloutInput);
844
+ rolloutForm.appendChild(rolloutBtn);
845
+ rolloutForm.addEventListener('submit', (event) => {
846
+ event.preventDefault();
847
+ void handleAdminFeatureFlagUpdate({
848
+ flagName,
849
+ isEnabled,
850
+ rolloutPercent: rolloutInput.value,
851
+ description,
852
+ });
853
+ });
854
+
855
+ const actions = [
856
+ createMiniButton(isEnabled ? 'Desativar' : 'Ativar', () =>
857
+ handleAdminFeatureFlagUpdate({
858
+ flagName,
859
+ isEnabled: !isEnabled,
860
+ rolloutPercent: rollout,
861
+ description,
862
+ }),
863
+ ),
864
+ ];
865
+
866
+ appendListItem({
867
+ container: ui.adminFlagsList,
868
+ title: flagName || 'feature_flag',
869
+ severity: isEnabled ? 'low' : 'medium',
870
+ badgeLabel: isEnabled ? 'ON' : 'OFF',
871
+ meta: [
872
+ `Rollout: ${rollout}%`,
873
+ description ? `Descrição: ${description}` : 'Descrição: n/d',
874
+ `Atualizado por: ${updatedBy || 'n/d'} • ${formatDateTime(flag?.updated_at)}`,
875
+ ],
876
+ actions,
877
+ customNode: rolloutForm,
878
+ });
879
+ }
880
+ };
881
+
882
+ const renderAlerts = (alerts) => {
883
+ const list = Array.isArray(alerts) ? alerts : [];
884
+ clearNode(ui.adminAlertsList);
885
+
886
+ if (!list.length) {
887
+ renderListPlaceholder(ui.adminAlertsList, 'Sem alertas ativos no momento.');
888
+ return;
889
+ }
890
+
891
+ for (const alert of list) {
892
+ const severity = normalizeString(alert?.severity || 'low');
893
+ const title = normalizeString(alert?.title || alert?.code || 'Alerta');
894
+ const meta = [
895
+ normalizeString(alert?.message || ''),
896
+ `Código: ${normalizeString(alert?.code || 'n/d')} • ${formatDateTime(alert?.created_at)}`,
897
+ ];
898
+ appendListItem({
899
+ container: ui.adminAlertsList,
900
+ title,
901
+ severity,
902
+ badgeLabel: severity.toUpperCase(),
903
+ meta,
904
+ });
905
+ }
906
+ };
907
+
908
+ const renderSearchResults = (payload = state.adminSearchPayload) => {
909
+ if (!ui.adminSearchResults) return;
910
+ clearNode(ui.adminSearchResults);
911
+
912
+ if (!payload || !isObject(payload)) {
913
+ renderListPlaceholder(ui.adminSearchResults, 'Faça uma busca para ver usuários, grupos, packs e sessões.');
914
+ return;
915
+ }
916
+
917
+ const q = normalizeString(payload?.q);
918
+ const totals = isObject(payload?.totals) ? payload.totals : {};
919
+ const results = isObject(payload?.results) ? payload.results : {};
920
+
921
+ appendListItem({
922
+ container: ui.adminSearchResults,
923
+ title: `Resultado para "${q || 'consulta'}"`,
924
+ severity: 'low',
925
+ badgeLabel: 'BUSCA',
926
+ meta: [
927
+ `Usuários: ${formatIntegerOrNd(totals.users)}`,
928
+ `Sessões: ${formatIntegerOrNd(totals.sessions)}`,
929
+ `Grupos: ${formatIntegerOrNd(totals.groups)}`,
930
+ `Packs: ${formatIntegerOrNd(totals.packs)}`,
931
+ ],
932
+ });
933
+
934
+ const users = Array.isArray(results.users) ? results.users : [];
935
+ for (const user of users) {
936
+ const identity = buildForceLogoutPayloadFromAny(user);
937
+ const actions = [];
938
+ if (Object.keys(identity).length) {
939
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
940
+ }
941
+ appendListItem({
942
+ container: ui.adminSearchResults,
943
+ title: `[Usuário] ${normalizeString(user?.name || user?.email || user?.owner_jid || 'registro')}`,
944
+ severity: 'low',
945
+ badgeLabel: 'USER',
946
+ meta: [`Email: ${normalizeString(user?.email) || 'n/d'}`, `Owner: ${normalizeString(user?.owner_jid) || 'n/d'}`],
947
+ actions,
948
+ });
949
+ }
950
+
951
+ const sessions = Array.isArray(results.sessions) ? results.sessions : [];
952
+ for (const session of sessions) {
953
+ const identity = buildForceLogoutPayloadFromAny(session);
954
+ const actions = [];
955
+ if (Object.keys(identity).length) {
956
+ actions.push(createMiniButton('Forçar logout', () => handleAdminForceLogout(identity, buildIdentityLabel(identity))));
957
+ }
958
+ appendListItem({
959
+ container: ui.adminSearchResults,
960
+ title: `[Sessão] ${normalizeString(session?.name || session?.email || session?.owner_jid || 'ativa')}`,
961
+ severity: 'low',
962
+ badgeLabel: 'SESSÃO',
963
+ meta: [`Email: ${normalizeString(session?.email) || 'n/d'}`, `Expira: ${formatDateTime(session?.expires_at)}`],
964
+ actions,
965
+ });
966
+ }
967
+
968
+ const groups = Array.isArray(results.groups) ? results.groups : [];
969
+ for (const group of groups) {
970
+ appendListItem({
971
+ container: ui.adminSearchResults,
972
+ title: `[Grupo] ${normalizeString(group?.subject || group?.id || 'grupo')}`,
973
+ severity: 'medium',
974
+ badgeLabel: 'GRUPO',
975
+ meta: [`ID: ${normalizeString(group?.id) || 'n/d'}`, `Atualizado: ${formatDateTime(group?.updated_at)}`],
976
+ });
977
+ }
978
+
979
+ const packs = Array.isArray(results.packs) ? results.packs : [];
980
+ for (const pack of packs) {
981
+ const packUrl = normalizeString(pack?.web_url);
982
+ const actions = [];
983
+ if (packUrl) actions.push(createMiniLink('Abrir pack', packUrl));
984
+ appendListItem({
985
+ container: ui.adminSearchResults,
986
+ title: `[Pack] ${normalizeString(pack?.name || pack?.pack_key || 'pack')}`,
987
+ severity: 'low',
988
+ badgeLabel: normalizeString(pack?.visibility || 'pack').toUpperCase(),
989
+ meta: [`Owner: ${normalizeString(pack?.owner_jid) || 'n/d'}`, `Stickers: ${formatIntegerOrNd(pack?.stickers_count)}`],
990
+ actions,
991
+ });
992
+ }
993
+
994
+ if (!users.length && !sessions.length && !groups.length && !packs.length) {
995
+ appendListItem({
996
+ container: ui.adminSearchResults,
997
+ title: 'Nenhum resultado encontrado.',
998
+ severity: 'low',
999
+ badgeLabel: 'VAZIO',
1000
+ meta: ['Tente outro termo de busca.'],
1001
+ });
1002
+ }
1003
+ };
1004
+
1005
+ const renderSystemHealth = (health) => {
1006
+ const statusMap = {
1007
+ ok: 'OK',
1008
+ degraded: 'Degradado',
1009
+ unknown: 'Indefinido',
1010
+ };
1011
+ setText(ui.adminHealthCpu, formatPercent(health?.cpu_percent));
1012
+ setText(ui.adminHealthRam, formatPercent(health?.ram_percent));
1013
+ setText(ui.adminHealthLatency, formatMilliseconds(health?.http_latency_p95_ms));
1014
+ setText(ui.adminHealthQueue, formatIntegerOrNd(health?.queue_pending));
1015
+ setText(ui.adminHealthDb, statusMap[normalizeString(health?.db_status)] || 'n/d');
1016
+ };
1017
+
1018
+ const renderAdminOverview = () => {
1019
+ const payload = state.adminOverviewPayload || {};
1020
+ const counters = isObject(payload?.counters) ? payload.counters : {};
1021
+ const dashboard = isObject(payload?.dashboard_quick) ? payload.dashboard_quick : {};
1022
+ const usersSessions = isObject(payload?.users_sessions) ? payload.users_sessions : {};
1023
+ const health = isObject(payload?.system_health) ? payload.system_health : {};
1024
+
1025
+ setText(ui.adminBotsOnline, formatIntegerOrNd(dashboard?.bots_online || 0));
1026
+ setText(ui.adminMessagesToday, formatIntegerOrNd(dashboard?.messages_today));
1027
+ setText(ui.adminSpamBlocked, formatIntegerOrNd(dashboard?.spam_blocked_today));
1028
+ setText(ui.adminUptime, normalizeString(dashboard?.uptime) || 'n/d');
1029
+ setText(ui.adminErrors5xx, formatIntegerOrNd(dashboard?.errors_5xx || 0));
1030
+
1031
+ setText(ui.adminTotalPacks, formatIntegerOrNd(counters?.total_packs_any_status || 0));
1032
+ setText(ui.adminTotalStickers, formatIntegerOrNd(counters?.total_stickers_any_status || 0));
1033
+ setText(ui.adminActiveBans, formatIntegerOrNd(counters?.active_bans || 0));
1034
+ setText(ui.adminKnownUsers, formatIntegerOrNd(counters?.known_google_users || 0));
1035
+ setText(ui.adminActiveSessions, formatIntegerOrNd(counters?.active_google_sessions || 0));
1036
+ setText(ui.adminVisits24h, formatIntegerOrNd(counters?.visit_events_24h || 0));
1037
+ setText(ui.adminVisits7d, formatIntegerOrNd(counters?.visit_events_7d || 0));
1038
+ setText(ui.adminUniqueVisitors7d, formatIntegerOrNd(counters?.unique_visitors_7d || 0));
1039
+
1040
+ renderSystemHealth(health);
1041
+ renderModerationQueue(payload?.moderation_queue);
1042
+ renderActiveSessions(usersSessions?.active_sessions);
1043
+ renderKnownUsers(usersSessions?.users);
1044
+ renderBlockedAccounts(usersSessions?.blocked_accounts);
1045
+ renderAuditLog(payload?.audit_log);
1046
+ renderFeatureFlags(payload?.feature_flags);
1047
+ renderAlerts(payload?.alerts);
1048
+ renderSearchResults();
1049
+ setText(ui.adminOpsStatus, state.adminOpsMessage || 'Ações operacionais disponíveis.');
1050
+ };
1051
+
1052
+ const renderAdminPanel = () => {
1053
+ if (!ui.adminPanel) return;
1054
+
1055
+ const enabled = state.adminStatusPayload?.enabled !== false;
1056
+ const authenticated = isAdminAuthenticated();
1057
+ const eligible = isAdminEligible();
1058
+ const role = resolveAdminRole();
1059
+
1060
+ if (!enabled || (!eligible && !authenticated)) {
1061
+ ui.adminPanel.hidden = true;
1062
+ if (ui.adminUnlockForm) ui.adminUnlockForm.hidden = true;
1063
+ if (ui.adminOverview) ui.adminOverview.hidden = true;
1064
+ showAdminError('');
1065
+ return;
1066
+ }
1067
+
1068
+ ui.adminPanel.hidden = false;
1069
+ setText(ui.adminRole, formatAdminRole(role));
1070
+
1071
+ if (authenticated) {
1072
+ setText(ui.adminStatus, `Sessão admin ativa como ${formatAdminRole(role)}. Ferramentas operacionais liberadas abaixo.`);
1073
+ if (ui.adminUnlockForm) ui.adminUnlockForm.hidden = true;
1074
+ if (ui.adminOverview) ui.adminOverview.hidden = false;
1075
+ renderAdminOverview();
1076
+ } else {
1077
+ setText(ui.adminStatus, `Conta elegível para admin (${formatAdminRole(role)}). Informe a senha para liberar os dados sensíveis.`);
1078
+ if (ui.adminUnlockForm) ui.adminUnlockForm.hidden = false;
1079
+ if (ui.adminOverview) ui.adminOverview.hidden = true;
1080
+ setAdminMetricsDefaults();
1081
+ }
1082
+
1083
+ setAdminBusy(state.adminBusy);
1084
+ };
1085
+
1086
+ const loadBotPhone = async () => {
1087
+ try {
1088
+ const payload = await fetchJson(botContactApiPath, { method: 'GET' });
1089
+ state.botPhone = normalizeDigits(payload?.data?.phone || '');
1090
+ } catch {
1091
+ state.botPhone = '';
1092
+ }
1093
+ if (ui.chatLink) ui.chatLink.href = buildWhatsAppMenuUrl(state.botPhone);
1094
+ };
1095
+
1096
+ const loadAdminStatus = async () => {
1097
+ const payload = await fetchJson(adminSessionApiPath, { method: 'GET' });
1098
+ state.adminStatusPayload = payload?.data || null;
1099
+ };
1100
+
1101
+ const loadAdminOverview = async () => {
1102
+ if (!isAdminAuthenticated()) {
1103
+ state.adminOverviewPayload = null;
1104
+ return;
1105
+ }
1106
+ const payload = await fetchJson(adminOverviewApiPath, { method: 'GET' });
1107
+ state.adminOverviewPayload = payload?.data || null;
1108
+ };
1109
+
1110
+ const refreshAdminArea = async ({ keepCurrentError = false } = {}) => {
1111
+ if (!keepCurrentError) showAdminError('');
1112
+ try {
1113
+ await loadAdminStatus();
1114
+ await loadAdminOverview();
1115
+ } catch (error) {
1116
+ if (error?.statusCode === 404) {
1117
+ state.adminStatusPayload = { enabled: false };
1118
+ state.adminOverviewPayload = null;
1119
+ } else {
1120
+ showAdminError(error?.message || 'Falha ao carregar área admin.');
1121
+ }
1122
+ }
1123
+ renderAdminPanel();
1124
+ };
1125
+
1126
+ const handleAdminUnlock = async () => {
1127
+ const password = normalizeString(ui.adminPassword?.value);
1128
+ if (!password) {
1129
+ showAdminError('Informe a senha do painel admin.');
1130
+ return;
1131
+ }
1132
+ if (state.adminBusy) return;
1133
+
1134
+ showAdminError('');
1135
+ setAdminBusy(true);
1136
+ try {
1137
+ const payload = await fetchJson(adminSessionApiPath, {
1138
+ method: 'POST',
1139
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
1140
+ body: JSON.stringify({ password }),
1141
+ });
1142
+ state.adminStatusPayload = payload?.data || null;
1143
+ state.adminOpsMessage = '';
1144
+ state.adminSearchPayload = null;
1145
+ if (ui.adminPassword) ui.adminPassword.value = '';
1146
+ await loadAdminOverview();
1147
+ } catch (error) {
1148
+ showAdminError(error?.message || 'Falha ao desbloquear área admin.');
1149
+ await loadAdminStatus().catch(() => {});
1150
+ state.adminOverviewPayload = null;
1151
+ } finally {
1152
+ setAdminBusy(false);
1153
+ renderAdminPanel();
1154
+ }
1155
+ };
1156
+
1157
+ const handleAdminLogout = async () => {
1158
+ if (state.adminBusy) return;
1159
+ showAdminError('');
1160
+ setAdminBusy(true);
1161
+ try {
1162
+ await fetchJson(adminSessionApiPath, { method: 'DELETE' });
1163
+ } catch {
1164
+ // no-op
1165
+ }
1166
+ state.adminOverviewPayload = null;
1167
+ state.adminSearchPayload = null;
1168
+ state.adminOpsMessage = '';
1169
+ await loadAdminStatus().catch(() => {
1170
+ state.adminStatusPayload = null;
1171
+ });
1172
+ setAdminBusy(false);
1173
+ renderAdminPanel();
1174
+ };
1175
+
1176
+ const handleAdminRefresh = async () => {
1177
+ if (state.adminBusy) return;
1178
+ setAdminBusy(true);
1179
+ await refreshAdminArea({ keepCurrentError: false });
1180
+ setAdminBusy(false);
1181
+ renderAdminPanel();
1182
+ };
1183
+
1184
+ const handleAdminSearchSubmit = async () => {
1185
+ if (state.adminBusy) return;
1186
+
1187
+ const q = normalizeString(ui.adminSearchInput?.value);
1188
+ if (!q) {
1189
+ state.adminSearchPayload = null;
1190
+ renderSearchResults();
1191
+ return;
1192
+ }
1193
+
1194
+ showAdminError('');
1195
+ setAdminBusy(true);
1196
+ try {
1197
+ const query = new URLSearchParams({ q, limit: '12' }).toString();
1198
+ const payload = await fetchJson(`${adminSearchApiPath}?${query}`, { method: 'GET' });
1199
+ state.adminSearchPayload = payload?.data || null;
1200
+ state.adminOpsMessage = `Busca concluída para "${q}".`;
1201
+ renderSearchResults();
1202
+ setText(ui.adminOpsStatus, state.adminOpsMessage);
1203
+ } catch (error) {
1204
+ showAdminError(error?.message || 'Falha ao buscar dados.');
1205
+ } finally {
1206
+ setAdminBusy(false);
1207
+ renderAdminPanel();
1208
+ }
1209
+ };
1210
+
1211
+ const downloadBlob = (blob, filename) => {
1212
+ const objectUrl = window.URL.createObjectURL(blob);
1213
+ const link = document.createElement('a');
1214
+ link.href = objectUrl;
1215
+ link.download = filename;
1216
+ document.body.appendChild(link);
1217
+ link.click();
1218
+ link.remove();
1219
+ window.setTimeout(() => {
1220
+ window.URL.revokeObjectURL(objectUrl);
1221
+ }, 250);
1222
+ };
1223
+
1224
+ const extractFilenameFromDisposition = (contentDisposition, fallbackName) => {
1225
+ const source = normalizeString(contentDisposition);
1226
+ if (!source) return fallbackName;
1227
+ const utf8Match = source.match(/filename\*=UTF-8''([^;]+)/i);
1228
+ if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
1229
+ const plainMatch = source.match(/filename="?([^"]+)"?/i);
1230
+ if (plainMatch?.[1]) return plainMatch[1];
1231
+ return fallbackName;
1232
+ };
1233
+
1234
+ const handleAdminExport = async ({ type = 'metrics', format = 'json' } = {}) => {
1235
+ if (state.adminBusy) return;
1236
+
1237
+ const normalizedType = normalizeString(type || 'metrics').toLowerCase();
1238
+ const normalizedFormat = normalizeString(format || 'json').toLowerCase();
1239
+ const fallbackName = `admin-${normalizedType}-${Date.now()}.${normalizedFormat === 'csv' ? 'csv' : 'json'}`;
1240
+
1241
+ showAdminError('');
1242
+ setAdminBusy(true);
1243
+ try {
1244
+ const query = new URLSearchParams({
1245
+ type: normalizedType,
1246
+ format: normalizedFormat,
1247
+ }).toString();
1248
+ const response = await fetchWithAuth(`${adminExportApiPath}?${query}`, { method: 'GET' });
1249
+
1250
+ if (normalizedFormat === 'csv') {
1251
+ const blob = await response.blob();
1252
+ const contentDisposition = response.headers.get('content-disposition');
1253
+ const filename = extractFilenameFromDisposition(contentDisposition, fallbackName);
1254
+ downloadBlob(blob, filename);
1255
+ } else {
1256
+ const payload = await response.json().catch(() => ({}));
1257
+ const blob = new Blob([JSON.stringify(payload?.data || payload || {}, null, 2)], { type: 'application/json; charset=utf-8' });
1258
+ downloadBlob(blob, fallbackName);
1259
+ }
1260
+
1261
+ state.adminOpsMessage = `Exportação ${normalizedType.toUpperCase()} (${normalizedFormat.toUpperCase()}) concluída.`;
1262
+ setText(ui.adminOpsStatus, state.adminOpsMessage);
1263
+ } catch (error) {
1264
+ showAdminError(error?.message || 'Falha ao exportar dados.');
1265
+ } finally {
1266
+ setAdminBusy(false);
1267
+ renderAdminPanel();
1268
+ }
1269
+ };
1270
+
184
1271
  const handleLogout = async () => {
185
1272
  if (!ui.logoutBtn) return;
186
1273
  ui.logoutBtn.disabled = true;
@@ -201,6 +1288,7 @@ if (root) {
201
1288
 
202
1289
  setText(ui.status, 'Validando sua sessão...');
203
1290
  showError('');
1291
+ setAdminMetricsDefaults();
204
1292
 
205
1293
  let sessionData = null;
206
1294
  try {
@@ -218,6 +1306,7 @@ if (root) {
218
1306
 
219
1307
  renderSession(sessionData);
220
1308
  await loadBotPhone();
1309
+ await refreshAdminArea();
221
1310
 
222
1311
  try {
223
1312
  const myProfilePayload = await fetchJson(myProfileApiPath, { method: 'GET' });
@@ -240,5 +1329,62 @@ if (root) {
240
1329
  });
241
1330
  }
242
1331
 
1332
+ if (ui.adminUnlockForm) {
1333
+ ui.adminUnlockForm.addEventListener('submit', (event) => {
1334
+ event.preventDefault();
1335
+ void handleAdminUnlock();
1336
+ });
1337
+ }
1338
+
1339
+ if (ui.adminLogoutBtn) {
1340
+ ui.adminLogoutBtn.addEventListener('click', () => {
1341
+ void handleAdminLogout();
1342
+ });
1343
+ }
1344
+
1345
+ if (ui.adminRefreshBtn) {
1346
+ ui.adminRefreshBtn.addEventListener('click', () => {
1347
+ void handleAdminRefresh();
1348
+ });
1349
+ }
1350
+
1351
+ if (ui.adminSearchForm) {
1352
+ ui.adminSearchForm.addEventListener('submit', (event) => {
1353
+ event.preventDefault();
1354
+ void handleAdminSearchSubmit();
1355
+ });
1356
+ }
1357
+
1358
+ if (ui.adminOpButtons.length) {
1359
+ for (const button of ui.adminOpButtons) {
1360
+ button.addEventListener('click', () => {
1361
+ const action = normalizeString(button.dataset.adminOpAction);
1362
+ if (!action) return;
1363
+ void handleAdminOpsAction(action);
1364
+ });
1365
+ }
1366
+ }
1367
+
1368
+ if (ui.adminExportMetricsJsonBtn) {
1369
+ ui.adminExportMetricsJsonBtn.addEventListener('click', () => {
1370
+ void handleAdminExport({ type: 'metrics', format: 'json' });
1371
+ });
1372
+ }
1373
+ if (ui.adminExportMetricsCsvBtn) {
1374
+ ui.adminExportMetricsCsvBtn.addEventListener('click', () => {
1375
+ void handleAdminExport({ type: 'metrics', format: 'csv' });
1376
+ });
1377
+ }
1378
+ if (ui.adminExportEventsJsonBtn) {
1379
+ ui.adminExportEventsJsonBtn.addEventListener('click', () => {
1380
+ void handleAdminExport({ type: 'events', format: 'json' });
1381
+ });
1382
+ }
1383
+ if (ui.adminExportEventsCsvBtn) {
1384
+ ui.adminExportEventsCsvBtn.addEventListener('click', () => {
1385
+ void handleAdminExport({ type: 'events', format: 'csv' });
1386
+ });
1387
+ }
1388
+
243
1389
  void init();
244
1390
  }