ligbox-ops-platform/projects/ops-desk/frontend/assets/app.js
Ligbox Spec Hub 41c0c2d428 Improve Infra page cards with wizard-style ws-panel aqua/teal layout.
Redesign SOC, purge auth, and integration panels; fix servicos-tile-icon CSS typo.
2026-06-19 22:25:17 +00:00

4140 lines
178 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const API = '/api';
async function api(path, options = {}) {
const res = await fetchWithTimeout(`${API}${path}`, {
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
...options,
});
if (res.status === 401) {
logout();
throw new Error('sessão expirada');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const detail = data.detail;
const msg = typeof detail === 'object' ? detail.message || JSON.stringify(detail) : (detail || `${res.status} ${path}`);
throw new Error(msg);
}
return res.json();
}
function fmtDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return iso;
}
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sessionHashHtml(sessionId, { full = true } = {}) {
const id = (sessionId || '').trim();
if (!id) return '';
const shown = full ? id : `${id.slice(0, 8)}${id.slice(-4)}`;
return `<code class="session-hash" title="Sessão onboarding VM112">${esc(shown)}</code>`;
}
let state = {
view: 'dashboard',
ticketFilter: 'all',
sourceFilter: 'all',
eventSourceFilter: 'all',
eventsTab: 'webhooks',
selectedTicketId: null,
selectedSessionId: null,
tickets: [],
summary: null,
scorecardTenant: null,
scorecardDomain: null,
accountLoaded: false,
overviewModal: { tenantId: null, view: 'list', domain: null, data: null, focus: 'onboard' },
overviewHomeWindow: '24h',
overviewHomeTrailFilter: 'all',
overviewHomeDnsDomain: null,
adminUsers: [],
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
adminSelected: null,
socWindow: '24h',
socLastEventId: null,
};
const views = {
dashboard: document.getElementById('view-dashboard'),
overview: document.getElementById('view-overview'),
'overview-home': document.getElementById('view-overview-home'),
tickets: document.getElementById('view-tickets'),
events: document.getElementById('view-events'),
tenants: document.getElementById('view-tenants'),
'email-migration': document.getElementById('view-email-migration'),
infra: document.getElementById('view-infra'),
infra2: document.getElementById('view-infra2'),
messages: document.getElementById('view-messages'),
admin: document.getElementById('view-admin'),
account: document.getElementById('view-account'),
leads: document.getElementById('view-leads'),
modules: document.getElementById('view-modules'),
};
function roleLabel(role) {
return ROLE_LABELS[role] || role;
}
const ROLE_LABELS = {
super_admin: 'Super Admin',
ops_lead: 'Chefe Ops',
technician: 'Suporte',
noc: 'NOC',
sales_admin: 'Sales Admin',
sales_support: 'Sales Support',
finance: 'Financeiro',
marketing: 'Marketing',
seo: 'SEO',
developer: 'Developer',
devops: 'DevOps',
security_analyst: 'Segurança / SOC',
content_editor: 'Conteúdo / CMS',
agentic_operator: 'Operador Agentes IA',
};
function statusLabel(status) {
return {
pending: 'pendente',
approved: 'aprovado',
rejected: 'rejeitado',
active: 'ativo',
open: 'aberto',
escalated: 'escalado',
assisting: 'assistindo',
resolved: 'resolvido',
closed: 'fechado',
}[status] || status;
}
function assistStatusLabel(status) {
return {
observing: 'observando',
escalated: 'escalado',
assisting: 'assistindo',
}[status] || status || 'observando';
}
function assistBadge(status) {
if (!status || status === 'observing') {
return '<span class="badge assist-observing">observando</span>';
}
const cls = status === 'assisting' ? 'assisting' : status === 'escalated' ? 'escalated' : 'open';
return `<span class="badge ${cls}">${esc(assistStatusLabel(status))}</span>`;
}
function setupSidebarUser() {
const user = getUser();
const sidebar = document.getElementById('sidebar-user');
const header = document.getElementById('header-user');
const logoutBtn = document.getElementById('btn-logout');
if (!user) return;
const label = roleLabel(user.role);
if (sidebar) {
sidebar.innerHTML = `
<strong>${esc(user.display_name || user.username)}</strong>
<span>${esc(user.username)} · ${esc(label)}</span>`;
}
if (header) {
header.hidden = false;
header.innerHTML = `<strong>${esc(user.display_name || user.username)}</strong><span>${esc(label)}</span>`;
}
if (logoutBtn) {
logoutBtn.hidden = false;
logoutBtn.onclick = logout;
}
}
function applyRoleNav() {
const user = getUser();
if (!user) return;
if (!canRunAudit()) {
document.getElementById('nav-overview')?.setAttribute('hidden', '');
document.getElementById('nav-overview-home')?.setAttribute('hidden', '');
}
if (user.role === 'noc') {
document.getElementById('nav-tenants')?.setAttribute('hidden', '');
const navEvents = document.getElementById('nav-events');
const navEventsLabel = navEvents?.querySelector('.nav-label');
if (navEventsLabel) navEventsLabel.textContent = 'Wazuh';
}
if (canManageUsers()) {
document.getElementById('nav-messages')?.removeAttribute('hidden');
document.getElementById('nav-admin')?.removeAttribute('hidden');
}
if (user.role === 'super_admin') {
document.getElementById('nav-modules')?.removeAttribute('hidden');
}
if (canReadLeads()) {
document.getElementById('nav-leads')?.removeAttribute('hidden');
document.getElementById('filter-leads')?.removeAttribute('hidden');
}
if (typeof canManageVm112Domains === 'function' && canManageVm112Domains()) {
document.getElementById('events-tab-purges')?.removeAttribute('hidden');
}
if (canRunAudit()) {
document.getElementById('events-tab-security')?.removeAttribute('hidden');
} else {
document.getElementById('events-tab-security')?.setAttribute('hidden', '');
}
if (canReadTickets()) {
document.getElementById('events-tab-carbonio')?.removeAttribute('hidden');
}
}
function setView(name) {
if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
name = 'dashboard';
}
if (state.view === 'account' && name !== 'account') {
state.accountLoaded = false;
}
state.view = name;
const titles = {
dashboard: 'Dashboard',
overview: 'Audit Overview',
'overview-home': 'Serviços',
tickets: 'Tickets',
events: 'Eventos webhook',
tenants: 'Tenants',
infra: 'Infraestrutura',
infra2: 'SOC — Infra 2',
messages: 'Mensagens — pedidos de cadastro',
admin: 'Administradores',
account: 'Minha conta',
leads: 'Leads abandonados',
modules: 'Módulos',
};
const subtitles = {
dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento',
overview: 'Visão por tenant — cards de auditoria (versão clássica)',
'overview-home': 'Desk VM122 · Orquestração MOSP',
tickets: 'Operações Ligbox — onboarding, tickets e monitoramento',
events: 'Operações Ligbox — onboarding, tickets e monitoramento',
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
infra: 'VM112, VM104 e integrações — visão técnica',
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
};
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
const subEl = document.getElementById('page-subtitle');
if (subEl) subEl.textContent = subtitles[name] || subtitles.dashboard;
document.querySelectorAll('.nav button').forEach((b) => {
b.classList.toggle('active', b.dataset.view === name);
});
Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name));
reschedulePoll();
refresh();
}
let pollTimer = null;
function reschedulePoll() {
if (pollTimer) clearInterval(pollTimer);
const ms = state.view === 'infra2' ? 15000 : 30000;
pollTimer = setInterval(() => refresh({ poll: true }), ms);
}
async function loadHealth() {
const el = document.getElementById('global-health');
try {
const h = await api('/health');
el.className = 'status-pill ok';
el.innerHTML = '<span class="dot"></span> API online';
return h;
} catch {
el.className = 'status-pill err';
el.innerHTML = '<span class="dot"></span> API offline';
return null;
}
}
async function renderDashboard() {
const box = document.getElementById('dashboard-content');
box.innerHTML = '<p class="loading">Carregando…</p>';
try {
const leadsPromise = canReadLeads()
? api('/v1/crm/leads').catch(() => ({ leads: [], total: 0 }))
: Promise.resolve({ leads: [], total: 0 });
const rankingPromise = canAssist()
? api('/v1/assist/technicians/ranking?window_days=30').catch(() => ({ ranking: [] }))
: Promise.resolve({ ranking: [] });
const [summary, funnel, audit, vm112, wazuh, leadsData, techRanking] = await Promise.all([
api('/v1/desk/summary').catch((e) => {
throw new Error(`Resumo indisponível: ${e.message}`);
}),
api('/v1/onboard/funnel').catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })),
canRunAudit() ? api('/v1/audit/overview').catch(() => ({ tenants: [] })) : Promise.resolve({ tenants: [] }),
api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })),
api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })),
leadsPromise,
rankingPromise,
]);
state.summary = summary;
const vmOk = vm112.vm112?.status === 'ok';
const wazuhOk = wazuh.api_online === true || wazuh.http_status === 401 || wazuh.http_status === 200;
const sessions = funnel.active_sessions || [];
const sessionCards = sessions.slice(0, 24).map((s) => {
const status = s.assist_status || 'observing';
const statusCls = status === 'assisting' ? 'assisting' : status === 'escalated' ? 'escalated' : 'observing';
return `
<button type="button" class="session-card clickable ${s.stale ? 'stale' : ''} session-card--${statusCls}" data-session="${esc(s.session_id || '')}">
<div class="session-card-top">
<span class="session-card-dot" aria-hidden></span>
<strong class="session-card-domain">${esc(s.domain || '—')}</strong>
</div>
<div class="session-card-stage">${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)} · onboarding VM112</div>
<div class="session-card-meta">
${sessionHashHtml(s.session_id)}
${s.ticket_id ? `<span class="session-card-ticket">#${s.ticket_id}</span>` : ''}
</div>
<div class="session-card-badges">
${assistBadge(status)}
${s.is_lead || s.crm_track === 'lead' ? '<span class="badge escalated">lead</span>' : ''}
${['company_validated','webmail_released','completed'].includes(s.current_stage) ? '<span class="badge billing">💳 billing</span>' : ''}
${s.stale && s.crm_track !== 'lead' && !s.is_lead ? '<span class="badge review">abandonado</span>' : ''}
</div>
</button>`;
}).join('');
box.innerHTML = `
<section class="dashboard-top">
<div class="dashboard-kpis">
${kpiCard('Abertos', summary.tickets_open, 'open')}
${kpiCard('Assistindo', summary.tickets_assisting ?? 0, 'assisting')}
${kpiCard('Escalados', summary.tickets_escalated ?? 0, 'escalated')}
${kpiCard('Sessões', funnel.sessions_total || 0, 'sessions', { title: 'Sessões onboarding — 48h' })}
${window.DeskModules?.isEnabled('billing-recurrence') ? kpiCard('Cobrança pendente', summary.billing_pending ?? 0, 'billing-pending', { title: 'Aguardam validação OPS' }) : ''}
${window.DeskModules?.isEnabled('billing-recurrence') ? kpiCard('Recorrência ativa', summary.billing_active ?? 0, 'billing-active', { title: 'Clientes com recorrência' }) : ''}
${canReadLeads() ? kpiCard('Leads', summary.leads_abandoned ?? leadsData.total ?? 0, 'leads', { clickable: true, viewJump: 'leads', title: 'Onboarding abandonado' }) : ''}
</div>
${dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk })}
</section>
<div class="dashboard-ops">
<div class="card dashboard-funnel">
<h3>Funil <span class="ticket-meta">48h</span></h3>
${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)}
</div>
<div class="card dashboard-sessions-panel">
<div class="card-head-row">
<h3>Sessões ativas</h3>
<div class="session-legend">
<span class="session-legend-item"><i class="dot-assisting"></i> Assistindo</span>
<span class="session-legend-item"><i class="dot-observing"></i> Observando</span>
<span class="ticket-meta">${sessions.length} total</span>
</div>
</div>
${sessionCards
? `<div class="session-grid">${sessionCards}</div>`
: '<p class="loading">Sem sessões recentes</p>'}
</div>
${canReadLeads() ? `
<div class="card dashboard-leads-panel">
<div class="card-head-row">
<h3>Leads abandonados</h3>
<button type="button" class="btn btn-ghost btn-sm" data-view-jump="leads">Ver todos</button>
</div>
${(leadsData.leads || []).slice(0, 6).map(leadRowHtml).join('') || '<p class="loading">Nenhum lead — sessões stale viram lead após ${summary.onboard_stale_hours ?? 24}h</p>'}
</div>` : ''}
<div class="card dashboard-tickets">
<h3>Tickets recentes</h3>
<div class="ticket-list ticket-list-compact">
${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '<p class="loading">Sem tickets</p>'}
</div>
</div>
</div>
${canAssist() && (techRanking.ranking || []).length ? `
<div class="card card-compact ranking-card">
<div class="card-head-row">
<h3>Ranking técnicos</h3>
<span class="ticket-meta">30d · assumidos / movimento</span>
</div>
${techRankingHtml(techRanking.ranking)}
</div>` : ''}`;
box.querySelectorAll('.ticket-row').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.id);
setView('tickets');
});
});
box.querySelectorAll('[data-session]').forEach((btn) => {
btn.addEventListener('click', () => {
const sess = sessions.find((s) => s.session_id === btn.dataset.session);
state.selectedSessionId = btn.dataset.session;
state.selectedTicketId = sess?.ticket_id || null;
setView('tickets');
});
});
box.querySelectorAll('[data-view-jump="leads"]').forEach((el) => {
el.addEventListener('click', () => setView('leads'));
});
box.querySelectorAll('[data-lead-ticket]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.leadTicket);
state.selectedSessionId = btn.dataset.leadSession || null;
setView('tickets');
});
});
} catch (e) {
box.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function sourceBadge(src) {
if (src === 'desk-registration') return '<span class="badge onboard">desk</span>';
if (src === 'wazuh') return '<span class="badge wazuh">wazuh</span>';
if (src === 'vm112-onboard') return '<span class="badge onboard">onboard</span>';
return src ? `<span class="badge">${esc(src)}</span>` : '';
}
function severityBadge(level) {
if (level == null) return '';
const n = Number(level);
let cls = 'sev-low';
if (n >= 12) cls = 'sev-critical';
else if (n >= 10) cls = 'sev-high';
else if (n >= 7) cls = 'sev-med';
return `<span class="badge ${cls}">L${n}</span>`;
}
const FUNNEL_LABELS = {
started: 'Iniciado',
domain_validated: 'Domínio OK',
dns_applied: 'DNS aplicado',
account_created: 'Conta criada',
infra_synced: 'Infra sync',
completed: 'Concluído',
failed: 'Falhou',
};
function funnelBarHtml(stages, total) {
const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed', 'failed'];
const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0));
return order
.filter((k) => k !== 'failed' || (stages.failed || 0) > 0)
.map((key) => {
const n = stages[key] || 0;
const pct = max ? Math.round((n / max) * 100) : 0;
return `
<div class="funnel-row">
<span class="funnel-label">${FUNNEL_LABELS[key] || key}</span>
<div class="funnel-bar"><div class="funnel-fill" style="width:${pct}%"></div></div>
<strong class="funnel-count">${n}</strong>
</div>`;
})
.join('');
}
function eventTypeLabel(ev) {
const key = ev?.event_type || ev?.event;
return SOC_EVENT_LABELS[key] || key || '—';
}
let _liveTimingTimer = null;
function formatDurationSec(seconds) {
if (seconds == null || Number.isNaN(seconds)) return '—';
const sec = Math.max(0, Math.round(Number(seconds)));
if (sec < 60) return `${sec}s`;
const mins = Math.floor(sec / 60);
const rem = sec % 60;
if (mins < 60) return `${mins}m ${rem}s`;
const hrs = Math.floor(mins / 60);
const m2 = mins % 60;
if (hrs < 48) return `${hrs}h ${m2}m`;
const days = Math.floor(hrs / 24);
const h2 = hrs % 24;
return `${days}d ${h2}h`;
}
function stopLiveTimingClock() {
if (_liveTimingTimer) {
clearInterval(_liveTimingTimer);
_liveTimingTimer = null;
}
}
function bindLiveTimingClock(root = document) {
stopLiveTimingClock();
const card = root.querySelector?.('[data-timing-live-card]');
if (!card || card.dataset.timingCompleted === 'true') return;
const startedAt = card.dataset.timingStartedAt;
const lastAt = card.dataset.timingLastAt || startedAt;
if (!startedAt) return;
const totalEl = card.querySelector('[data-timing-live="total"]');
const idleEl = card.querySelector('[data-timing-live="idle"]');
const accEl = card.querySelector('[data-timing-live="accumulated"]');
const tick = () => {
const now = Date.now();
const startMs = new Date(startedAt).getTime();
const lastMs = new Date(lastAt).getTime();
if (!Number.isNaN(startMs) && totalEl) {
totalEl.textContent = formatDurationSec((now - startMs) / 1000);
}
if (!Number.isNaN(lastMs) && idleEl) {
idleEl.textContent = formatDurationSec((now - lastMs) / 1000);
}
if (!Number.isNaN(startMs) && accEl) {
accEl.textContent = `Σ ${formatDurationSec((now - startMs) / 1000)}`;
}
};
tick();
_liveTimingTimer = setInterval(tick, 1000);
}
function phaseTimingCardHtml(timing, events) {
if (!timing || !window.DeskModules?.isEnabled('funnel-timing') || !events?.length) return '';
const statusBadge = timing.is_completed
? '<span class="badge ok">concluído</span>'
: `<span class="badge review">em curso</span>`;
const lastEv = events[events.length - 1];
const rows = events.map((ev, idx) => {
const prev = idx > 0 ? (ev.duration_from_prev_label || '—') : '—';
const isLastLive = !timing.is_completed && idx === events.length - 1;
const total = isLastLive
? `<span data-timing-live="accumulated">Σ ${esc(timing.total_duration_label)}</span>`
: `Σ ${esc(ev.duration_from_start_label || '—')}`;
return `
<tr>
<td>${esc(eventTypeLabel(ev))}</td>
<td class="timing-cell-time">${fmtDate(ev.created_at || ev.at)}</td>
<td class="timing-cell-delta">${idx > 0 ? `<strong>+${esc(prev)}</strong>` : '—'}</td>
<td class="timing-cell-total">${total}</td>
</tr>`;
}).join('');
return `
<div class="card phase-timing-card" data-timing-live-card
data-timing-completed="${timing.is_completed ? 'true' : 'false'}"
data-timing-started-at="${esc(timing.started_at || '')}"
data-timing-last-at="${esc(timing.last_event_at || lastEv?.created_at || lastEv?.at || timing.started_at || '')}">
<div class="phase-timing-head">
<div>
<h4 style="margin:0">Relógio por fase</h4>
<p class="ticket-meta">Duração entre etapas do onboarding VM112</p>
</div>
${statusBadge}
</div>
<div class="phase-timing-kpis">
<div class="phase-timing-kpi">
<span class="phase-timing-kpi-label">Tempo total</span>
<span class="phase-timing-kpi-value${timing.is_completed ? '' : ' phase-timing-kpi-value--live'}"
${timing.is_completed ? '' : ' data-timing-live="total"'}>${esc(timing.total_duration_label)}</span>
</div>
<div class="phase-timing-kpi">
<span class="phase-timing-kpi-label">Início</span>
<span class="phase-timing-kpi-value phase-timing-kpi-value--sm">${fmtDate(timing.started_at)}</span>
</div>
${timing.is_completed ? `
<div class="phase-timing-kpi">
<span class="phase-timing-kpi-label">Concluído</span>
<span class="phase-timing-kpi-value phase-timing-kpi-value--sm">${fmtDate(timing.completed_at)}</span>
</div>` : `
<div class="phase-timing-kpi">
<span class="phase-timing-kpi-label">Parado há</span>
<span class="phase-timing-kpi-value${timing.is_completed ? '' : ' phase-timing-kpi-value--live'}"
data-timing-live="idle">${esc(timing.idle_since_label || '—')}</span>
</div>`}
</div>
<div class="table-wrap">
<table class="phase-timing-table">
<thead><tr><th>Fase</th><th>Registado</th><th>Δ fase</th><th>Acumulado</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}
function timingSummaryHtml(timing) {
if (!timing || !window.DeskModules?.isEnabled('funnel-timing')) return '';
const idle = timing.is_completed ? '' : `<span class="timing-stat">Parado há <strong>${esc(timing.idle_since_label)}</strong></span>`;
return `
<div class="timing-summary">
<span class="timing-stat">Total <strong>${esc(timing.total_duration_label)}</strong></span>
${idle}
${timing.completed_at ? `<span class="timing-stat">Concluído ${fmtDate(timing.completed_at)}</span>` : ''}
</div>`;
}
function timelineHtml(events, timingMeta, opts = {}) {
if (!events?.length) return '';
const showTiming = !opts.compact && window.DeskModules?.isEnabled('funnel-timing');
return `${!opts.compact ? timingSummaryHtml(timingMeta) : ''}<ol class="timeline">${events
.map(
(e, idx) => {
const evt = e.event_type || e.event || '—';
const at = e.created_at || e.at;
const prevDur = showTiming && idx > 0 && e.duration_from_prev_label && e.duration_from_prev_label !== '—'
? `<span class="timing-badge" title="Desde fase anterior">+${esc(e.duration_from_prev_label)}</span>`
: '';
const fromStart = showTiming && e.duration_from_start_label
? `<span class="timing-badge timing-badge--muted" title="Desde início">Σ ${esc(e.duration_from_start_label)}</span>`
: '';
return `
<li class="timeline-item">
<span class="timeline-dot"></span>
<div>
<strong>${esc(evt)}</strong>
${e.stage ? `<span class="badge open">${esc(e.stage)}</span>` : ''}
${prevDur}${fromStart}
<div class="ticket-meta">${fmtDate(at)}</div>
</div>
</li>`;
}
)
.join('')}</ol>`;
}
function healthBadge(status) {
const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' };
const cls = map[status] || 'open';
return `<span class="badge ${cls} health-${esc(status)}">${esc(status || 'unknown')}</span>`;
}
function checkStatusBadge(status) {
const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open';
return `<span class="badge ${cls}">${esc(status)}</span>`;
}
function leadRowHtml(l) {
return `
<button type="button" class="lead-row" data-lead-ticket="${l.ticket_id}" data-lead-session="${esc(l.session_id || '')}">
<div class="lead-row-top">
<strong>${esc(l.domain || '—')}</strong>
<span class="badge escalated">lead</span>
</div>
<div class="ticket-meta">${esc(l.email || 'sem e-mail')} · ${esc(FUNNEL_LABELS[l.funnel_stage] || l.funnel_stage || '—')}</div>
<div class="ticket-meta">#${l.ticket_id} · parado ${fmtDate(l.last_event_at)}</div>
</button>`;
}
function billingTicketIcon(t) {
if ((t.subject || '').includes('[billing-validation]') || t.billing_state) return ' 💳';
return '';
}
function normalizeAssistStatus(status) {
if (status === 'active') return 'assisting';
return status;
}
function ticketFunnelKvHtml(t) {
const latest = t.latest_funnel_event || t.event;
const opened = t.event_opened || t.event;
const showOpened = opened && latest && opened !== latest;
const outcome = t.onboarding_outcome;
const outcomeBadge = outcome === 'completed'
? '<span class="badge ok">concluído</span>'
: outcome === 'failed'
? '<span class="badge escalated">falhou</span>'
: '';
const label = latest ? (SOC_EVENT_LABELS[latest] || latest) : '—';
const sev = latest && typeof socEventSeverity === 'function' ? socEventSeverity(latest) : 'open';
return `
<dt>Estado funil</dt><dd><span class="badge ${sev}">${esc(label)}</span> ${outcomeBadge}</dd>
${showOpened ? `<dt>Abertura</dt><dd>${esc(SOC_EVENT_LABELS[opened] || opened)}</dd>` : ''}`;
}
function ticketRowHtml(t) {
const review = t.needs_review ? '<span class="badge review">revisão</span>' : '';
const verified = t.account_verified ? '<span class="badge ok">verificado</span>' : '';
const lead = t.crm_track === 'lead' ? '<span class="badge escalated">lead</span>' : '';
const isOnboard = t.source === 'vm112-onboard' || t.event?.startsWith?.('onboarding') || t.event === 'session.started';
const sub = t.event === 'wazuh.alert'
? esc(t.description || t.subject)
: isOnboard && !t.domain
? `Onboarding VM112 · ${esc(FUNNEL_LABELS[t.lead_funnel_stage] || t.event || 'iniciado')}`
: esc(t.domain || t.subject);
const metaParts = [];
if (isOnboard && t.session_id) metaParts.push(sessionHashHtml(t.session_id));
if (t.event === 'wazuh.alert') {
metaParts.push(esc(t.agent || t.domain || ''));
} else if (t.email) {
metaParts.push(esc(t.email));
}
metaParts.push(fmtDate(t.created_at));
if (t.assigned_to) metaParts.push(esc(t.assigned_to));
const meta = metaParts.filter(Boolean).join(' · ');
return `
<button type="button" class="ticket-row ${state.selectedTicketId === t.id ? 'selected' : ''}" data-id="${t.id}"${t.session_id ? ` data-session="${esc(t.session_id)}"` : ''}>
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
<div>
<div class="ticket-subject">${sub}</div>
<div class="ticket-meta ticket-meta--hash">${meta}</div>
</div>
<div>${lead}${sourceBadge(t.source)}${severityBadge(t.severity)}${review}${verified}</div>
</button>`;
}
function assistActionsHtml(sessionId, meta, consoleExtra = {}) {
if (!canAssist() || !sessionId) return '';
const canAct = meta?.can_escalate;
const status = meta?.assist_status || meta?.ticket_status;
const isAssisting = status === 'assisting';
const isEscalated = status === 'escalated';
const deskActions = (consoleExtra.actions || []).map((a) =>
`<button type="button" class="btn btn-ghost btn-sm${a.danger ? ' btn-danger' : ''}" data-desk-action="${esc(a.id)}" title="${esc(a.label)}">${esc(a.label)}</button>`
).join('');
const links = (consoleExtra.links || []).map((l) =>
`<a class="btn btn-ghost btn-sm" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.label)}</a>`
).join('');
const audit = (meta?.actions || []).slice(-8).map((a) =>
`<li><code>${esc(a.action)}</code> · ${esc(a.actor)} · ${fmtDate(a.created_at)}</li>`
).join('');
return `
<div class="assist-panel">
<h4>Console de assistência</h4>
<p class="ticket-meta">${assistBadge(status)}${meta?.assisted_by ? ` · ${esc(meta.assisted_by)}` : ''}</p>
<div class="actions" style="margin-top:0.75rem;flex-wrap:wrap">
${!isAssisting && !isEscalated && canAct ? `<button type="button" class="btn btn-ghost btn-sm" data-assist="escalate">Escalar</button>` : ''}
${canAct && !isAssisting ? `<button type="button" class="btn btn-primary btn-sm" data-assist="takeover">Assumir sessão</button>` : ''}
${isAssisting ? `<button type="button" class="btn btn-ghost btn-sm" data-assist="handoff">Devolver ao cliente</button>` : ''}
${!canAct ? '<span class="ticket-meta">Intervenção disponível após domínio validado</span>' : ''}
</div>
${deskActions ? `<div class="assist-console-actions"><h5>Acções Desk</h5><div class="actions">${deskActions}</div></div>` : ''}
${links ? `<div class="assist-console-links"><h5>Links externos</h5><div class="actions">${links}</div></div>` : ''}
${audit ? `<h5 style="margin-top:0.75rem">Movimento / audit</h5><ul class="audit-mini">${audit}</ul>` : ''}
</div>`;
}
async function loadAssistMeta(sessionId) {
if (!sessionId) return null;
try {
const [meta, actionsRes, linksRes] = await Promise.all([
api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}`),
api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions`).catch(() => ({ actions: [] })),
api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/links`).catch(() => ({ links: [] })),
]);
return { ...meta, _console: { actions: actionsRes.actions || [], links: linksRes.links || [] } };
} catch {
return null;
}
}
async function runAssistAction(action, sessionId) {
const path = `/v1/assist/sessions/${encodeURIComponent(sessionId)}/${action}`;
const result = await api(path, { method: 'POST' });
if (action === 'takeover' && result.takeover_url) {
window.open(result.takeover_url, '_blank', 'noopener');
}
return result;
}
function bindAssistActions(container, sessionId) {
container.querySelectorAll('[data-assist]').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await runAssistAction(btn.dataset.assist, sessionId);
await renderTickets();
} catch (e) {
alert(e.message || 'Falha na ação de assistência');
} finally {
btn.disabled = false;
}
});
});
container.querySelectorAll('[data-desk-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const actionId = btn.dataset.deskAction;
if (actionId === 'onboarding.abort' && !confirm('Abortar onboarding desta sessão?')) return;
btn.disabled = true;
try {
await api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions/${encodeURIComponent(actionId)}`, { method: 'POST' });
await renderTickets();
} catch (e) {
alert(e.message || 'Falha na ação');
} finally {
btn.disabled = false;
}
});
});
}
function kpiCard(label, value, variant, opts = {}) {
const click = opts.clickable ? ' kpi-card--click' : '';
const jump = opts.viewJump ? ` data-view-jump="${opts.viewJump}"` : '';
const title = opts.title ? ` title="${esc(opts.title)}"` : '';
return `
<div class="kpi-card kpi-card--${variant}${click}"${jump}${title}>
<span class="kpi-card-glow" aria-hidden="true"></span>
<div class="kpi-card-inner">
<span class="kpi-value">${value}</span>
<span class="kpi-label">${esc(label)}</span>
</div>
</div>`;
}
function dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk }) {
const tenants = audit.tenants || [];
const auditChips = tenants.map((t) => {
const cls = t.status === 'healthy' ? 'ok' : t.status === 'degraded' ? 'warn' : 'alert';
return `
<div class="pulse-chip pulse-chip--${cls}">
<span class="pulse-dot" aria-hidden="true"></span>
<div class="pulse-body">
<strong>${esc(t.name)}</strong>
<span>${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks</span>
</div>
${healthBadge(t.status)}
</div>`;
}).join('');
return `
<div class="dashboard-pulse">
${auditChips}
<div class="pulse-chip pulse-chip--${vmOk ? 'ok' : 'warn'}">
<span class="pulse-dot" aria-hidden="true"></span>
<div class="pulse-body">
<strong>VM112 Portal</strong>
<span>${esc(vm112.vm112?.service || vm112.error || '—')}</span>
</div>
<span class="badge ${vmOk ? 'ok' : 'review'}">${vmOk ? 'online' : 'check'}</span>
</div>
<div class="pulse-chip pulse-chip--${wazuhOk ? 'ok' : 'warn'}">
<span class="pulse-dot" aria-hidden="true"></span>
<div class="pulse-body">
<strong>VM104 Wazuh</strong>
<span>API ${wazuh.http_status ?? '—'}</span>
</div>
<span class="badge ${wazuhOk ? 'ok' : 'review'}">${wazuhOk ? 'online' : 'check'}</span>
</div>
</div>`;
}
function techRankingHtml(ranking) {
if (!ranking?.length) return '<p class="ticket-meta">Sem movimento no período</p>';
return `
<table class="ranking-table">
<thead><tr><th>#</th><th>Técnico</th><th>Assumidos</th><th>Escalados</th><th>Acções</th><th>Score</th></tr></thead>
<tbody>
${ranking.slice(0, 8).map((r, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${esc(r.username)}</strong></td>
<td>${r.assumidos}</td>
<td>${r.escalados}</td>
<td>${r.acoes}</td>
<td><strong>${r.score}</strong></td>
</tr>`).join('')}
</tbody>
</table>`;
}
function dnsPurposeLabel(purpose) {
return {
mx: 'MX',
spf: 'SPF',
dkim: 'DKIM',
dmarc: 'DMARC',
'mail-host': 'Mail host',
autodiscover: 'Autodiscover',
'mail-alias': 'Alias',
other: 'Outro',
}[purpose] || purpose || '—';
}
async function fetchCloudflareDns(domain, emailService) {
try {
return await api(
`/v1/dns/cloudflare/records?domain=${encodeURIComponent(domain)}&email_service=${emailService ? 'true' : 'false'}`
);
} catch (e) {
return {
domain,
records: [],
email_records: [],
summary: { total: 0, email_related: 0 },
error: e.message || 'Falha ao carregar DNS Cloudflare',
};
}
}
function isEmailServiceDomain(tenantId, funnelStage) {
return tenantId === 1 || ['dns_applied', 'account_created', 'infra_synced', 'completed', 'company_validated', 'webmail_released'].includes(funnelStage);
}
async function showOverviewHomeDnsPanel(domain, tenantId, funnelStage, domainMeta = null) {
const panel = document.getElementById('cf-dns-panel-body');
const label = document.getElementById('cf-dns-domain-label');
if (!panel) return;
state.overviewHomeDnsDomain = domain;
if (label) label.textContent = domain;
panel.innerHTML = `<p class="cf-dns-empty">Carregando detalhes de <strong>${esc(domain)}</strong>…</p>`;
let timing = domainMeta?.timing;
let timeline = domainMeta?.timeline;
if (window.DeskModules?.isEnabled('funnel-timing') && (!timing || !timeline?.length) && tenantId) {
try {
const details = await api(`/v1/audit/tenants/${tenantId}/details`);
const match = (details.domains || []).find((item) => item.domain === domain);
timing = match?.timing || timing;
timeline = match?.timeline || timeline;
} catch {
/* mantém o que tiver */
}
}
const timingCard = phaseTimingCardHtml(timing, timeline);
const dns = await fetchCloudflareDns(domain, isEmailServiceDomain(tenantId, funnelStage));
panel.innerHTML = `${timingCard}${htmlCloudflareDnsCardInline(dns)}`;
}
function htmlCloudflareDnsCardInline(dns) {
if (!dns) {
return '<p class="cf-dns-empty">Dados DNS indisponíveis.</p>';
}
if (dns.error && !dns.records?.length) {
return `
<p class="cf-dns-error">${esc(dns.error)}</p>
${dns.email_service ? '<p class="cf-dns-meta">Serviço: servidor de e-mail (onboarding)</p>' : ''}`;
}
const rows = (dns.records || []).map((r) => `
<tr class="${r.email_related ? 'dns-email-row' : ''}">
<td><span class="dns-purpose-badge purpose-${esc(r.purpose || 'other')}">${esc(dnsPurposeLabel(r.purpose))}</span></td>
<td><code>${esc(r.name)}</code></td>
<td><strong>${esc(r.type)}</strong></td>
<td class="dns-content">${esc(r.content)}</td>
</tr>`).join('');
const summary = dns.summary || {};
const zone = dns.zone || {};
return `
<div class="cf-dns-inline-summary">
<div class="cf-metric-stat">
<strong>${summary.total || 0}</strong>
<span>registos na zona</span>
</div>
<div class="cf-metric-stat">
<strong>${summary.email_related || 0}</strong>
<span>para e-mail</span>
</div>
<span class="badge ${dns.email_service ? 'onboard' : 'open'}">${dns.email_service ? 'E-mail' : 'DNS'}</span>
</div>
<p class="cf-dns-meta">Zona <code>${esc(zone.name || '—')}</code>${dns.error ? ` · ${esc(dns.error)}` : ''}</p>
<div class="cf-dns-table-wrap">
<table class="data-table dns-records-table dns-records-table-compact">
<thead><tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">Sem registos para este domínio.</td></tr>'}</tbody>
</table>
</div>`;
}
function htmlCloudflareDnsCard(dns) {
if (!dns) {
return `
<div class="modal-section dns-records-section">
<h4>Apontamentos DNS (Cloudflare)</h4>
<p class="loading">Dados DNS indisponíveis.</p>
</div>`;
}
if (dns.error && !dns.records?.length) {
return `
<div class="modal-section">
<h4>Apontamentos DNS (Cloudflare)</h4>
<p class="ticket-meta">${esc(dns.error)}</p>
${dns.email_service ? '<p class="ticket-meta">Serviço: servidor de e-mail (onboarding)</p>' : ''}
</div>`;
}
const rows = (dns.records || []).map((r) => `
<tr class="${r.email_related ? 'dns-email-row' : ''}">
<td><span class="dns-purpose-badge purpose-${esc(r.purpose || 'other')}">${esc(dnsPurposeLabel(r.purpose))}</span></td>
<td><code>${esc(r.name)}</code></td>
<td><strong>${esc(r.type)}</strong>${r.priority != null ? ` <span class="ticket-meta">prio ${r.priority}</span>` : ''}</td>
<td class="dns-content">${esc(r.content)}</td>
<td class="ticket-meta">${r.proxied ? 'proxy' : 'DNS only'} · TTL ${r.ttl ?? '—'}</td>
</tr>`).join('');
const summary = dns.summary || {};
const zone = dns.zone || {};
return `
<div class="modal-section dns-records-section">
<div class="card-head-row">
<h4>Apontamentos DNS (Cloudflare)</h4>
<span class="badge ${dns.email_service ? 'onboard' : 'open'}">${dns.email_service ? 'Servidor de e-mail' : 'DNS geral'}</span>
</div>
<p class="ticket-meta" style="margin:0 0 0.65rem">
Zona <code>${esc(zone.name || '—')}</code> · ${summary.total || 0} registo(s)
· ${summary.email_related || 0} para e-mail
${dns.error ? ` · <span class="badge review">${esc(dns.error)}</span>` : ''}
</p>
<div class="table-wrap">
<table class="data-table dns-records-table">
<thead>
<tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th><th>Estado</th></tr>
</thead>
<tbody>${rows || '<tr><td colspan="5">Sem registos DNS para este domínio na zona Cloudflare.</td></tr>'}</tbody>
</table>
</div>
</div>`;
}
function executionStatusBadge(status) {
const map = {
in_progress: ['assisting', 'em execução'],
completed: ['ok', 'concluído'],
failed: ['escalated', 'falhou'],
registered: ['open', 'registado'],
};
const [cls, label] = map[status] || ['open', status || '—'];
return `<span class="badge ${cls}">${esc(label)}</span>`;
}
function bindOverviewModal() {
document.querySelectorAll('[data-close-overview-modal]').forEach((el) => {
el.addEventListener('click', closeOverviewModal);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeOverviewModal();
});
}
function closeOverviewModal() {
const modal = document.getElementById('overview-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
state.overviewModal = { tenantId: null, view: 'list', domain: null, data: null, focus: 'onboard' };
}
function renderWazuhOverviewCard(t) {
const issues = (t.top_issues || [])
.slice(0, 3)
.map((i) => `<li><code>${esc(i.domain)}</code> · ${esc(i.check_id)}${esc(i.message || i.status)}</li>`)
.join('');
const apiLabel = t.api_online ? `API online (${t.http_status || '—'})` : 'API offline';
return `
<button type="button" class="card health-card health-${esc(t.status)} health-card--click health-card--wazuh" data-open-overview="${t.tenant_id}">
<div class="health-card-head">
<div>
<h3 style="margin:0">${esc(t.name)}</h3>
<p class="ticket-meta">${esc(t.ip)} · <strong>${t.alerts_total || 0}</strong> alerta(s) · <strong>${t.agents_count || 0}</strong> agente(s)</p>
</div>
${healthBadge(t.status)}
</div>
<div class="health-score wazuh-score">
<span>${t.alerts_high || 0} alto (L≥${t.min_ticket_level || 10})</span>
<span>${t.open_tickets || 0} ticket(s) aberto(s)</span>
<span class="${t.api_online ? 'wazuh-api-ok' : 'wazuh-api-bad'}">${esc(apiLabel)}</span>
</div>
<p class="ticket-meta">Último alerta: ${fmtDate(t.last_alert_at)}</p>
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem alertas Wazuh registados — integração ativa aguarda eventos.</p>'}
<p class="health-card-hint">Clique para ver agentes, alertas por nível e tickets SOC</p>
</button>`;
}
function renderWazuhSocModal(data) {
const body = document.getElementById('overview-modal-body');
const title = document.getElementById('overview-modal-title');
const sub = document.getElementById('overview-modal-sub');
if (!body || !title || !sub) return;
const s = data.summary || {};
title.textContent = data.name || 'Wazuh SOC';
sub.textContent = `${data.ip || '—'} · API ${s.api_online ? 'online' : 'offline'} · gerado ${fmtDate(data.generated_at)}`;
const agentRows = (data.agents || []).map((a) => `
<tr>
<td><strong>${esc(a.agent)}</strong></td>
<td>${esc(a.agent_ip || '—')}</td>
<td>${a.alerts_count}</td>
<td>L${a.max_level}</td>
<td>${relativeTimeAgo(a.last_seen)}</td>
</tr>`).join('');
const alertRows = (data.alerts || []).slice(0, 40).map((a) => `
<tr>
<td>${severityBadge(a.level)}</td>
<td>${esc(a.agent)}</td>
<td>${esc(a.description || '—')}</td>
<td>${esc(a.srcip || '—')}</td>
<td>${esc(a.agent_ip || '—')}</td>
<td>${relativeTimeAgo(a.created_at)}</td>
</tr>`).join('');
const ticketRows = (data.tickets || []).slice(0, 15).map((t) => `
<button type="button" class="ticket-row" data-open-ticket="${t.id}">
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
<div>
<div class="ticket-subject">${esc(t.subject)}</div>
<div class="ticket-meta">#${t.id} · ${fmtDate(t.created_at)}</div>
</div>
</button>`).join('');
body.innerHTML = `
<div class="overview-summary wazuh-summary">
<div class="overview-stat"><strong>${s.alerts_total || 0}</strong><span>Alertas</span></div>
<div class="overview-stat"><strong>${s.alerts_24h || 0}</strong><span>24h</span></div>
<div class="overview-stat"><strong>${s.agents_total || 0}</strong><span>Agentes</span></div>
<div class="overview-stat"><strong>${s.level_10_plus || 0}</strong><span>L≥${data.min_ticket_level || 10}</span></div>
<div class="overview-stat"><strong>${s.open_tickets || 0}</strong><span>Tickets</span></div>
</div>
<p class="ticket-meta" style="margin:0 0 0.75rem">
Monitorização de segurança VM104 — webhooks <code>wazuh.alert</code> com nível ≥ ${data.min_ticket_level || 10} geram ticket na VM122.
</p>
<div class="wazuh-modal-grid">
<div class="modal-section">
<h4>Agentes monitorados</h4>
${agentRows ? `
<div class="wazuh-table-wrap">
<table class="wazuh-table">
<thead><tr><th>Agente</th><th>IP</th><th>Alertas</th><th>Máx</th><th>Último</th></tr></thead>
<tbody>${agentRows}</tbody>
</table>
</div>` : '<p class="loading">Nenhum agente com alertas registados.</p>'}
</div>
<div class="modal-section">
<h4>Feed de alertas</h4>
${alertRows ? `
<div class="wazuh-table-wrap">
<table class="wazuh-table">
<thead><tr><th>Nível</th><th>Agente</th><th>Descrição</th><th>Src IP</th><th>Agent IP</th><th>Hora</th></tr></thead>
<tbody>${alertRows}</tbody>
</table>
</div>` : '<p class="loading">Sem alertas.</p>'}
</div>
</div>
${ticketRows ? `
<div class="modal-section" style="margin-top:0.75rem">
<h4>Tickets Wazuh</h4>
<div class="ticket-list ticket-list-compact">${ticketRows}</div>
</div>` : ''}`;
body.querySelectorAll('[data-open-ticket]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.openTicket);
closeOverviewModal();
setView('tickets');
});
});
}
function wizardSecuritySeverityBadge(sev) {
const map = { high: ['escalated', 'Alto'], critical: ['escalated', 'Crítico'], warn: ['review', 'Atenção'], info: ['open', 'Info'] };
const [cls, label] = map[sev] || ['open', sev || '—'];
return `<span class="badge ${cls}">${esc(label)}</span>`;
}
function wizardSecurityEventLabel(ev) {
return SECURITY_EVENT_LABELS[ev] || SOC_EVENT_LABELS[ev] || ev || '—';
}
const SECURITY_EVENT_LABELS = {
'security.csp_violation': 'Violação CSP',
'security.input_warn': 'Input suspeito',
'security.input_blocked': 'Input bloqueado',
'security.rate_limited': 'Rate limit',
'security.handoff_created': 'Handoff criado',
'security.handoff_consumed': 'Handoff consumido',
'security.handoff_rejected': 'Handoff rejeitado',
'security.handoff_expired': 'Handoff expirado',
'security.auth_failed': 'Auth portal falhou',
'security.session_anomaly': 'Anomalia sessão',
};
const WIZARD_SEC_COLORS = {
teal: '#0d9488',
tealLight: '#14b8a6',
orange: '#ea580c',
orangeLight: '#f97316',
severe: '#7f1d1d',
high: '#dc2626',
elevated: '#ea580c',
guarded: '#eab308',
low: '#22c55e',
na: '#94a3b8',
csp: '#0891b2',
input: '#dc2626',
handoff: '#ea580c',
auth: '#7c3aed',
rate: '#64748b',
};
function wizardSecRiskScore(severity, eventType) {
if (severity === 'critical') return 5;
if (severity === 'high' || (eventType || '').includes('blocked') || (eventType || '').includes('rejected')) return 4;
if (severity === 'warn' || (eventType || '').includes('csp')) return 3;
if (severity === 'info') return 2;
return 1;
}
function wizardSecRiskCell(score) {
const map = {
5: ['Severo', WIZARD_SEC_COLORS.severe],
4: ['Alto', WIZARD_SEC_COLORS.high],
3: ['Elevado', WIZARD_SEC_COLORS.elevated],
2: ['Vigiado', WIZARD_SEC_COLORS.guarded],
1: ['Baixo', WIZARD_SEC_COLORS.low],
0: ['N/A', WIZARD_SEC_COLORS.na],
};
const [label, bg] = map[score] || map[0];
return `<span class="ws-threat-level" style="background:${bg}">${esc(label)}</span>`;
}
function wizardSecDonutSvg(segments, size = 130) {
const filtered = segments.filter((s) => s.value > 0);
const total = filtered.reduce((a, s) => a + s.value, 0) || 1;
const r = 42;
const cx = size / 2;
const cy = size / 2;
const circ = 2 * Math.PI * r;
let offset = 0;
const arcs = filtered.map((s) => {
const len = (s.value / total) * circ;
const el = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${s.color}" stroke-width="16" stroke-dasharray="${len} ${circ - len}" stroke-dashoffset="${-offset}" transform="rotate(-90 ${cx} ${cy})"/>`;
offset += len;
return el;
}).join('');
return `<svg class="ws-donut" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">${arcs}<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="middle" font-size="18" font-weight="700" fill="#0f172a">${total}</text></svg>`;
}
function wizardSecVBarSvg(items, width = 260, height = 120) {
if (!items.length) return '<p class="ws-empty-chart">Sem dados</p>';
const max = Math.max(...items.map((i) => i.value), 1);
const gap = 10;
const barW = Math.max(18, (width - gap * (items.length + 1)) / items.length);
const bars = items.map((item, i) => {
const bh = Math.max(2, (item.value / max) * (height - 36));
const x = gap + i * (barW + gap);
const y = height - 24 - bh;
return `
<rect x="${x}" y="${y}" width="${barW}" height="${bh}" fill="${item.color}" rx="3"/>
<text x="${x + barW / 2}" y="${height - 6}" text-anchor="middle" font-size="9" fill="#64748b">${esc(item.short || item.label)}</text>
<text x="${x + barW / 2}" y="${y - 4}" text-anchor="middle" font-size="10" font-weight="600" fill="#0f172a">${item.value}</text>`;
}).join('');
return `<svg class="ws-vbar" viewBox="0 0 ${width} ${height}" width="100%" height="${height}" aria-hidden="true">${bars}</svg>`;
}
function wizardSecHBarHtml(items) {
if (!items.length) return '<p class="ws-empty-chart">Sem dados</p>';
const max = Math.max(...items.map((i) => i.value), 1);
return items.map((item) => `
<div class="ws-hbar-row">
<span class="ws-hbar-label">${esc(item.label)}</span>
<div class="ws-hbar-track"><div class="ws-hbar-fill" style="width:${Math.round((item.value / max) * 100)}%;background:${item.color}"></div></div>
<span class="ws-hbar-val">${item.value}</span>
</div>`).join('');
}
function wizardSecVectorBucket(eventType) {
const ev = eventType || '';
if (ev.includes('csp')) return 'csp';
if (ev.includes('input') || ev.includes('rate')) return 'input';
if (ev.includes('handoff')) return 'handoff';
if (ev.includes('auth') || ev.includes('session')) return 'auth';
return 'outro';
}
function wizardSecAccessStatus(s) {
if ((s.inputs_blocked || 0) + (s.handoffs_rejected || 0) > 0) return 'critical';
if ((s.total || 0) > 0) return 'degraded';
return 'healthy';
}
function renderUserAccessOverviewCard(sec) {
if (!window.DeskModules?.isEnabled('wizard-security')) return '';
const s = sec || { total: 0, inputs_blocked: 0, handoffs_rejected: 0, csp_violations: 0, sessions_with_alerts: 0, recent: [] };
const status = wizardSecAccessStatus(s);
const issues = (s.recent || []).slice(0, 3).map((ev) =>
`<li><code>${esc((ev.client_ip || '—'))}</code> · ${esc(wizardSecurityEventLabel(ev.event_type))}${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : 'sem sessão'}</li>`
).join('');
return `
<button type="button" class="card health-card health-${status} health-card--click ws-access-overview-card" data-open-user-access="1">
<div class="health-card-head">
<div>
<h3 style="margin:0">Acesso Usuário — Cybersecurity</h3>
<p class="ticket-meta">Portal público · browser · handoff · <strong>não</strong> é o wizard VM112</p>
</div>
${healthBadge(status)}
</div>
<div class="health-score">${s.total || 0} alerta(s) · ${s.inputs_blocked || 0} bloq · ${s.handoffs_rejected || 0} handoff · ${s.csp_violations || 0} CSP</div>
<p class="ticket-meta">${s.sessions_with_alerts || 0} sessão(ões) · janela 24h</p>
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem alertas de acesso nas últimas 24h</p>'}
<p class="health-card-hint">Clique para ver dashboard de ameaças, guia técnico e relatório</p>
</button>`;
}
function renderWizardSecurityCard(sec, opts = {}) {
const standalone = opts.standalone === true;
if (!window.DeskModules?.isEnabled('wizard-security')) return '';
const s = sec || { total: 0, csp_violations: 0, inputs_blocked: 0, handoffs_rejected: 0, sessions_with_alerts: 0, recent: [], by_event: {} };
const byEvent = s.by_event || {};
const recent = s.recent || [];
const severityCounts = { high: 0, warn: 0, info: 0 };
recent.forEach((ev) => {
const sev = ev.severity || 'info';
if (sev === 'high' || sev === 'critical') severityCounts.high += 1;
else if (sev === 'warn') severityCounts.warn += 1;
else severityCounts.info += 1;
});
const eventBars = Object.entries(byEvent).map(([ev, count]) => ({
label: wizardSecurityEventLabel(ev),
short: wizardSecurityEventLabel(ev).split(' ')[0],
value: count,
color: ev.includes('blocked') || ev.includes('rejected') ? WIZARD_SEC_COLORS.high
: ev.includes('csp') ? WIZARD_SEC_COLORS.csp
: ev.includes('handoff') ? WIZARD_SEC_COLORS.handoff
: WIZARD_SEC_COLORS.teal,
})).slice(0, 6);
const vectors = { csp: 0, input: 0, handoff: 0, auth: 0 };
Object.entries(byEvent).forEach(([ev, count]) => {
const bucket = wizardSecVectorBucket(ev);
if (vectors[bucket] != null) vectors[bucket] += count;
});
const vectorBars = [
{ label: 'CSP Browser', value: vectors.csp, color: WIZARD_SEC_COLORS.csp },
{ label: 'Input audit', value: vectors.input, color: WIZARD_SEC_COLORS.input },
{ label: 'Handoff', value: vectors.handoff, color: WIZARD_SEC_COLORS.handoff },
{ label: 'Auth/Sessão', value: vectors.auth, color: WIZARD_SEC_COLORS.auth },
];
const ipMap = {};
recent.forEach((ev) => {
const ip = ev.client_ip || '—';
ipMap[ip] = (ipMap[ip] || 0) + 1;
});
const topIps = Object.entries(ipMap).sort((a, b) => b[1] - a[1]).slice(0, 5);
const riskBars = [
{ label: 'Input bloqueado', value: s.inputs_blocked || 0, color: WIZARD_SEC_COLORS.high },
{ label: 'Handoff rejeitado', value: s.handoffs_rejected || 0, color: WIZARD_SEC_COLORS.orange },
{ label: 'Violação CSP', value: s.csp_violations || 0, color: WIZARD_SEC_COLORS.csp },
{ label: 'Input suspeito', value: s.inputs_warn || 0, color: WIZARD_SEC_COLORS.guarded },
{ label: 'Rate limit', value: s.rate_limited || 0, color: WIZARD_SEC_COLORS.na },
];
const threatRows = recent.slice(0, 8).map((ev) => {
const score = wizardSecRiskScore(ev.severity, ev.event_type);
return `
<tr class="wizard-sec-row" data-wizard-sec-session="${esc(ev.session_id || '')}">
<td>${esc(wizardSecurityEventLabel(ev.event_type))}</td>
<td>${wizardSecRiskCell(score)}</td>
<td>${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : '—'}</td>
<td><code>${esc(ev.client_ip || '—')}</code></td>
<td>${fmtDate(ev.created_at)}</td>
</tr>`;
}).join('');
const accessStatus = wizardSecAccessStatus(s);
const issueLines = recent.slice(0, 3).map((ev) =>
`<li><code>${esc(ev.client_ip || '—')}</code> · ${esc(wizardSecurityEventLabel(ev.event_type))}${ev.session_id ? `<code>${esc(ev.session_id.slice(0, 14))}…</code>` : 'sem sessão'}</li>`
).join('');
const dashboardGrid = `
<div class="ws-dash-grid ws-dash-grid--inner">
<article class="ws-panel ws-panel--teal">
<header class="ws-panel-head ws-panel-head--teal">Eventos nas últimas 24h</header>
<div class="ws-panel-body">${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}</div>
</article>
<article class="ws-panel ws-panel--orange">
<header class="ws-panel-head ws-panel-head--orange">Risco actual</header>
<div class="ws-panel-body ws-panel-body--donut">
${wizardSecDonutSvg([
{ value: severityCounts.high, color: WIZARD_SEC_COLORS.high },
{ value: severityCounts.warn, color: WIZARD_SEC_COLORS.elevated },
{ value: severityCounts.info, color: WIZARD_SEC_COLORS.low },
])}
<ul class="ws-legend">
<li><span style="background:${WIZARD_SEC_COLORS.high}"></span>Alto (${severityCounts.high})</li>
<li><span style="background:${WIZARD_SEC_COLORS.elevated}"></span>Elevado (${severityCounts.warn})</li>
<li><span style="background:${WIZARD_SEC_COLORS.low}"></span>Baixo (${severityCounts.info})</li>
</ul>
</div>
</article>
<article class="ws-panel ws-panel--teal">
<header class="ws-panel-head ws-panel-head--teal">Ameaças por vetor</header>
<div class="ws-panel-body">${wizardSecVBarSvg(vectorBars)}</div>
</article>
<article class="ws-panel ws-panel--orange">
<header class="ws-panel-head ws-panel-head--orange">IPs com atividade</header>
<div class="ws-panel-body ws-panel-body--ips">
${topIps.length ? topIps.map(([ip, n], i) => `
<div class="ws-ip-row">
<span class="ws-ip-rank">${i + 1}</span>
<code class="ws-ip-addr">${esc(ip)}</code>
<span class="ws-ip-count" style="background:${i === 0 && n > 1 ? WIZARD_SEC_COLORS.high : WIZARD_SEC_COLORS.orange}">${n} evt</span>
</div>`).join('') : '<p class="ws-empty-chart">Nenhum IP registado</p>'}
</div>
</article>
<article class="ws-panel ws-panel--teal">
<header class="ws-panel-head ws-panel-head--teal">Risco por categoria</header>
<div class="ws-panel-body">${wizardSecHBarHtml(riskBars)}</div>
</article>
<article class="ws-panel ws-panel--orange">
<header class="ws-panel-head ws-panel-head--orange">Relatório de ameaças</header>
<div class="ws-panel-body ws-panel-body--table">
<table class="ws-threat-table">
<thead><tr><th>Ameaça</th><th>Nível</th><th>Sessão</th><th>IP</th><th>Hora</th></tr></thead>
<tbody>${threatRows || '<tr><td colspan="5">Sem ameaças nas últimas 24h</td></tr>'}</tbody>
</table>
</div>
</article>
</div>`;
return `
<section class="ws-access-zone" id="overview-access-security" aria-label="Acesso de usuário — cibersegurança">
<div class="ws-zone-banner">
<span class="ws-zone-tag">Área independente</span>
<h3 class="ws-zone-title">Acesso de usuário — Cibersegurança</h3>
<p class="ws-zone-desc">Eventos gerados quando alguém acede ao <strong>portal público</strong>, preenche formulários ou faz login (handoff). Isto é <em>segurança de acesso</em> — não mede DNS, Carbonio, certificados nem progresso do wizard VM112.</p>
</div>
<div class="card health-card health-${accessStatus} ws-access-health-card">
<div class="health-card-head">
<div>
<h3 style="margin:0">Threat tracking — portal &amp; sessões</h3>
<p class="ticket-meta">Browser · CSP · inputs · handoff · Spec 021</p>
</div>
${healthBadge(accessStatus)}
</div>
<div class="health-score">${s.total || 0} alerta(s) · ${s.inputs_blocked || 0} bloq · ${s.handoffs_rejected || 0} handoff · ${s.csp_violations || 0} CSP · ${s.sessions_with_alerts || 0} sessões</div>
<p class="ticket-meta">Janela ${s.window_hours || 24}h · origem <code>vm112-security</code></p>
${issueLines ? `<ul class="issue-list">${issueLines}</ul>` : '<p class="ticket-meta">Nenhum incidente de acesso nas últimas 24h</p>'}
<div class="ws-dash-inner">${dashboardGrid}</div>
<footer class="ws-dash-foot">
<button type="button" class="btn btn-ghost btn-sm" data-wizard-sec-goto-events>Ver todos em Eventos → Segurança</button>
${standalone ? '<button type="button" class="btn btn-ghost btn-sm" data-open-onboard-from-access>Ver VM112 Onboard →</button>' : ''}
</footer>
</div>
<div class="ws-info-cards-row">
<article class="card ws-info-card ws-info-card--teal">
<header class="ws-info-card-head ws-info-card-head--teal">O que monitorizamos</header>
<div class="ws-info-card-body">
<p>Este painel cobre apenas o <strong>comportamento de quem acede</strong> ao sistema — visitantes, clientes no portal e tentativas de abuso em formulários públicos.</p>
<ul class="ws-info-list">
<li><strong>CSP (browser)</strong> — scripts ou recursos bloqueados no navegador do usuário</li>
<li><strong>Input audit</strong> — padrões SQL/XSS em campos enviados pelo usuário</li>
<li><strong>Handoff</strong> — token de login expirado, reutilizado ou inválido</li>
<li><strong>Auth / sessão</strong> — falhas de autenticação ou anomalias de sessão</li>
</ul>
<p class="ws-info-note">${standalone
? 'Domínios, DNS e Carbonio estão no card <strong>VM112 Ligbox Onboard</strong> — área separada.'
: '≠ Saúde do wizard VM112 (domínios, e-mail, certificados) — ver secção <em>Onboard</em> abaixo.'}</p>
</div>
</article>
<article class="card ws-info-card ws-info-card--orange">
<header class="ws-info-card-head ws-info-card-head--orange">Como proceder — guia técnico</header>
<div class="ws-info-card-body">
<ol class="ws-info-steps">
<li><strong>Input bloqueado / CSP</strong> — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.</li>
<li><strong>Handoff rejeitado</strong> — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).</li>
<li><strong>Correlacionar</strong> — Tickets → Onboard → hash da sessão. Compare com funil.</li>
<li><strong>Takeover</strong> — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.</li>
<li><strong>Falso positivo</strong> — Domínios com caracteres especiais podem gerar <em>input_warn</em>.</li>
<li><strong>Escalação</strong> — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.</li>
</ol>
</div>
</article>
</div>
</section>
${standalone ? '' : `
<div class="ws-zone-divider" role="separator">
<span>VM112 Ligbox Onboard — wizard, domínios &amp; infraestrutura</span>
</div>`}`;
}
function bindWizardSecurityCard(root) {
root.querySelector('[data-wizard-sec-goto-events]')?.addEventListener('click', () => {
state.eventsTab = 'security';
state.eventSourceFilter = 'vm112-security';
closeOverviewModal();
setView('events');
});
root.querySelector('[data-open-onboard-from-access]')?.addEventListener('click', () => {
openOverviewModal(1, { focus: 'onboard' });
});
root.querySelectorAll('[data-wizard-sec-session]').forEach((row) => {
const sid = row.dataset.wizardSecSession;
if (!sid) return;
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
state.selectedSessionId = sid;
closeOverviewModal();
setView('tickets');
});
});
}
function renderOverviewModalList(data) {
if (data.kind === 'wazuh_soc' && !window.DeskModules?.isEnabled('wazuh-soc')) {
data = { ...data, kind: 'audit', domains: data.domains || [] };
}
if (data.kind === 'wazuh_soc') {
renderWazuhSocModal(data);
return;
}
const body = document.getElementById('overview-modal-body');
const title = document.getElementById('overview-modal-title');
const sub = document.getElementById('overview-modal-sub');
if (!body || !title || !sub) return;
const s = data.summary || {};
const focus = state.overviewModal?.focus || 'onboard';
const showAccess = focus === 'access' && data.tenant_id === 1 && window.DeskModules?.isEnabled('wizard-security');
if (showAccess) {
const sec = data.security || {};
title.textContent = 'Acesso Usuário — Cybersecurity';
sub.textContent = `Portal & sessões · ${sec.total || 0} alerta(s) 24h · ${sec.sessions_with_alerts || 0} sessão(ões) · gerado ${fmtDate(data.generated_at)}`;
body.innerHTML = renderWizardSecurityCard(sec, { standalone: true });
bindWizardSecurityCard(body);
return;
}
title.textContent = data.name || 'Detalhes do tenant';
sub.textContent = `${data.ip || '—'} · ${s.domains_total || 0} domínio(s) · wizard & infra · gerado ${fmtDate(data.generated_at)}`;
const rows = (data.domains || []).map((d) => {
const issuePreview = (d.issues || []).slice(0, 2).map((i) =>
`<li><code>${esc(i.check_id)}</code> — ${esc(i.message || i.status)}</li>`
).join('');
return `
<button type="button" class="overview-domain-row" data-overview-domain="${esc(d.domain)}">
<div class="overview-domain-top">
<strong>${esc(d.domain)}</strong>
<div style="display:flex;gap:0.35rem;flex-wrap:wrap">
${executionStatusBadge(d.execution_status)}
${healthBadge(d.audit_status)}
</div>
</div>
<div class="overview-domain-meta">
<span>${esc(d.email || 'sem e-mail')}</span>
<span>${esc(d.funnel_stage_label || d.funnel_stage || '—')}</span>
<span>início ${fmtDate(d.started_at)}</span>
<span>último ${fmtDate(d.last_event_at)}</span>
${d.timing && window.DeskModules?.isEnabled('funnel-timing') ? `<span>total ${esc(d.timing.total_duration_label)}</span>` : ''}
<span>IP ${esc(d.client_ip || '—')}</span>
${d.ticket_id ? `<span>ticket #${d.ticket_id}</span>` : ''}
</div>
${issuePreview ? `<ul class="overview-domain-issues">${issuePreview}</ul>` : ''}
</button>`;
}).join('');
body.innerHTML = `
<section class="overview-tenant-zone" id="overview-tenant-onboard">
<div class="overview-zone-label">
<h4>VM112 Ligbox Onboard</h4>
<p class="ticket-meta">Saúde do wizard, domínios em onboarding, DNS, certificados e Carbonio</p>
${window.DeskModules?.isEnabled('wizard-security') ? '<button type="button" class="btn btn-ghost btn-sm" data-open-access-from-onboard>← Acesso Usuário — Cybersecurity</button>' : ''}
</div>
<div class="overview-summary">
<div class="overview-stat"><strong>${s.domains_total || 0}</strong><span>Total</span></div>
<div class="overview-stat"><strong>${s.in_progress || 0}</strong><span>Em execução</span></div>
<div class="overview-stat"><strong>${s.completed || 0}</strong><span>Concluídos</span></div>
<div class="overview-stat"><strong>${s.failed || 0}</strong><span>Falharam</span></div>
<div class="overview-stat"><strong>${s.with_issues || 0}</strong><span>Com erros</span></div>
</div>
<p class="ticket-meta" style="margin:0 0 0.75rem">Clique num domínio para ver apontamentos DNS Cloudflare, timeline, checks e IP de acesso.</p>
${rows || '<p class="loading">Nenhum domínio auditado neste tenant.</p>'}
</section>`;
body.querySelector('[data-open-access-from-onboard]')?.addEventListener('click', () => {
openUserAccessModal();
});
body.querySelectorAll('[data-overview-domain]').forEach((btn) => {
btn.addEventListener('click', () => openOverviewDomainDetail(btn.dataset.overviewDomain));
});
}
async function openOverviewDomainDetail(domain) {
const body = document.getElementById('overview-modal-body');
const data = state.overviewModal.data;
if (!body || !data) return;
const d = (data.domains || []).find((item) => item.domain === domain);
if (!d) return;
state.overviewModal.view = 'domain';
state.overviewModal.domain = domain;
body.innerHTML = '<p class="loading">Carregando detalhes…</p>';
let checks = d.issues || [];
const isEmailService = isEmailServiceDomain(data.tenant_id, d.funnel_stage);
try {
const sc = await api(`/v1/audit/tenants/${data.tenant_id}/scorecard?domain=${encodeURIComponent(domain)}`);
checks = sc.checks || checks;
} catch {
/* usa issues já carregados */
}
const dnsData = await fetchCloudflareDns(domain, isEmailService);
const checkRows = checks.map((c) => `
<tr>
<td>${esc(c.label || c.check_id)}</td>
<td>${checkStatusBadge(c.status)}</td>
<td>${esc(c.message || '—')}</td>
<td>${fmtDate(c.checked_at)}</td>
</tr>`).join('');
const timelineBlock = d.timeline?.length
? `${phaseTimingCardHtml(d.timing, d.timeline)}<h4 style="margin:1rem 0 0.5rem">Eventos</h4>${timelineHtml(d.timeline, d.timing, { compact: true })}`
: '<p class="loading">Sem eventos webhook para este domínio.</p>';
const ips = (d.client_ips || []).filter(Boolean);
body.innerHTML = `
<div class="modal-breadcrumb">
<button type="button" class="btn btn-ghost btn-sm" data-overview-back>← Voltar à lista</button>
<span class="ticket-meta">${esc(data.name)}</span>
</div>
<div class="health-card-head">
<div>
<h3 style="margin:0">${esc(d.domain)}</h3>
<p class="ticket-meta">${esc(d.email || 'sem e-mail')} · sessão <code>${esc((d.session_id || '—').slice(0, 18))}</code></p>
</div>
<div style="display:flex;gap:0.35rem;flex-wrap:wrap">
${executionStatusBadge(d.execution_status)}
${healthBadge(d.audit_status)}
</div>
</div>
<dl class="detail-kv">
<div><dt>Etapa funil</dt><dd>${esc(d.funnel_stage_label || d.funnel_stage || '—')}</dd></div>
<div><dt>Início</dt><dd>${fmtDate(d.started_at)}</dd></div>
<div><dt>Último evento</dt><dd>${esc(d.last_event || '—')} · ${fmtDate(d.last_event_at)}</dd></div>
<div><dt>Último audit</dt><dd>${fmtDate(d.last_audit_at)}</dd></div>
<div><dt>IP de acesso</dt><dd><code>${esc(d.client_ip || (ips[0] || '—'))}</code></dd></div>
<div><dt>Ticket</dt><dd>${d.ticket_id ? `#${d.ticket_id} (${esc(d.ticket_status || '—')})` : '—'}</dd></div>
</dl>
${ips.length > 1 ? `<p class="ticket-meta">IPs observados: ${ips.map((ip) => `<code>${esc(ip)}</code>`).join(' · ')}</p>` : ''}
${htmlCloudflareDnsCard(dnsData)}
<div class="modal-section">
<h4>Checks de auditoria</h4>
<div class="table-wrap">
<table>
<thead><tr><th>Check</th><th>Status</th><th>Mensagem</th><th>Verificado</th></tr></thead>
<tbody>${checkRows || '<tr><td colspan="4">Sem checks</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="modal-section">
<h4>Timeline de execução</h4>
${timelineBlock}
</div>
${d.ticket_id ? `<div class="actions"><button type="button" class="btn btn-primary btn-sm" data-open-ticket="${d.ticket_id}">Abrir ticket #${d.ticket_id}</button></div>` : ''}`;
body.querySelector('[data-overview-back]')?.addEventListener('click', () => renderOverviewModalList(data));
body.querySelector('[data-open-ticket]')?.addEventListener('click', (btn) => {
state.selectedTicketId = Number(btn.target.dataset.openTicket);
closeOverviewModal();
setView('tickets');
});
}
async function openOverviewModal(tenantId, options = {}) {
const modal = document.getElementById('overview-modal');
const body = document.getElementById('overview-modal-body');
if (!modal || !body) return;
const focus = options.focus || 'onboard';
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
body.innerHTML = '<p class="loading">Carregando detalhes…</p>';
try {
const data = await api(`/v1/audit/tenants/${tenantId}/details`);
state.overviewModal = { tenantId, view: 'list', domain: null, data, focus };
renderOverviewModalList(data);
} catch (e) {
console.error('openOverviewModal', e);
body.innerHTML = `
<p class="loading">Erro: ${esc(e.message)}</p>
<button type="button" class="btn btn-ghost btn-sm" data-retry-overview-modal>Tentar novamente</button>`;
body.querySelector('[data-retry-overview-modal]')?.addEventListener('click', () => {
openOverviewModal(tenantId, { focus });
});
}
}
async function openUserAccessModal() {
const modal = document.getElementById('overview-modal');
const body = document.getElementById('overview-modal-body');
const title = document.getElementById('overview-modal-title');
const sub = document.getElementById('overview-modal-sub');
if (!modal || !body) return;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
body.innerHTML = '<p class="loading">Carregando segurança de acesso…</p>';
try {
const sec = await api('/v1/security/summary?window_hours=24');
const generatedAt = new Date().toISOString();
const data = {
tenant_id: 1,
name: 'Acesso Usuário — Cybersecurity',
generated_at: generatedAt,
security: sec,
};
state.overviewModal = { tenantId: 1, view: 'list', domain: null, data, focus: 'access' };
if (title) title.textContent = 'Acesso Usuário — Cybersecurity';
if (sub) {
sub.textContent = `Portal & sessões · ${sec.total || 0} alerta(s) 24h · ${sec.sessions_with_alerts || 0} sessão(ões) · gerado ${fmtDate(generatedAt)}`;
}
body.innerHTML = renderWizardSecurityCard(sec, { standalone: true });
bindWizardSecurityCard(body);
} catch (e) {
console.error('openUserAccessModal', e);
body.innerHTML = `
<p class="loading">Erro ao carregar segurança de acesso: ${esc(e.message)}</p>
<p class="ticket-meta">Verifique ligação ao Desk e permissões de audit.</p>
<button type="button" class="btn btn-ghost btn-sm" data-retry-user-access>Tentar novamente</button>`;
body.querySelector('[data-retry-user-access]')?.addEventListener('click', () => openUserAccessModal());
}
}
async function renderOverview() {
const el = document.getElementById('overview-content');
el.innerHTML = '<p class="loading">Carregando overview…</p>';
try {
const secPromise = window.DeskModules?.isEnabled('wizard-security')
? api('/v1/security/summary?window_hours=24').catch(() => null)
: Promise.resolve(null);
const [data, secSummary] = await Promise.all([
api('/v1/audit/overview'),
secPromise,
]);
const cards = [];
if (secSummary?.enabled !== false && window.DeskModules?.isEnabled('wizard-security')) {
const accessCard = renderUserAccessOverviewCard(secSummary);
if (accessCard) cards.push(accessCard);
}
(data.tenants || []).forEach((t) => {
if (t.kind === 'wazuh_soc' && window.DeskModules?.isEnabled('wazuh-soc')) {
cards.push(renderWazuhOverviewCard(t));
return;
}
const issues = (t.top_issues || [])
.slice(0, 3)
.map((i) => `<li><code>${esc(i.domain)}</code> · ${esc(i.check_id)}${esc(i.message || i.status)}</li>`)
.join('');
cards.push(`
<button type="button" class="card health-card health-${esc(t.status)} health-card--click" data-open-overview="${t.tenant_id}">
<div class="health-card-head">
<div>
<h3 style="margin:0">${esc(t.name)}</h3>
<p class="ticket-meta">${esc(t.ip)} · <strong>${t.domains_count || 0}</strong> domínio(s) · wizard &amp; infra</p>
</div>
${healthBadge(t.status)}
</div>
<div class="health-score">${t.score?.pass ?? 0}/${t.score?.total ?? 8} pass · ${t.score?.warn ?? 0} warn · ${t.score?.fail ?? 0} fail</div>
<p class="ticket-meta">Último audit: ${fmtDate(t.last_audit_at)}</p>
${issues ? `<ul class="issue-list">${issues}</ul>` : '<p class="loading">Sem issues ou aguarde o 1º ciclo de auditoria</p>'}
<p class="health-card-hint">Clique para ver domínios, DNS, checks e timeline do onboarding</p>
</button>`);
});
el.innerHTML = cards.length
? `<div class="health-grid">${cards.join('')}</div>`
: '<p class="loading">Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.</p>';
el.querySelectorAll('[data-open-overview]').forEach((btn) => {
btn.addEventListener('click', () => {
openOverviewModal(Number(btn.dataset.openOverview), { focus: 'onboard' });
});
});
el.querySelectorAll('[data-open-user-access]').forEach((btn) => {
btn.addEventListener('click', () => openUserAccessModal());
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function overviewHomeWindowHours() {
return { '24h': 24, '7d': 168, '30d': 720 }[state.overviewHomeWindow] || 24;
}
function isInWindow(iso, hours) {
if (!iso) return false;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return false;
return Date.now() - t <= hours * 3600000;
}
function relativeTimeAgo(iso) {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
if (diff < 0) return 'agora';
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'agora';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 48) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function sparklineSvg(values, color = '#2f6fed') {
const w = 118;
const h = 34;
const pad = 3;
const data = values?.length ? values : [0, 0, 0, 0, 0, 0];
const max = Math.max(...data, 1);
const pts = data.map((v, i) => {
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - pad * 2);
const y = h - pad - (v / max) * (h - pad * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg class="cf-spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true"><polyline fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${pts}"/></svg>`;
}
function bucketEvents(events, windowHours, buckets = 12) {
const out = Array(buckets).fill(0);
const now = Date.now();
const start = now - windowHours * 3600000;
for (const ev of events) {
const t = new Date(ev.at || ev.created_at).getTime();
if (Number.isNaN(t) || t < start) continue;
const idx = Math.min(buckets - 1, Math.floor(((t - start) / (windowHours * 3600000)) * buckets));
out[idx] += 1;
}
return out;
}
function domainStatusDot(status) {
if (status === 'healthy') return 'ok';
if (status === 'degraded') return 'warn';
if (status === 'critical') return 'bad';
return 'unknown';
}
function buildOverviewHomeTrail(events, domainsFlat, filter, windowHours) {
const rows = [];
for (const ev of events) {
if (!isInWindow(ev.created_at, windowHours)) continue;
const p = ev.payload || {};
const source = ev.source || p.source || 'unknown';
if (filter === 'onboard' && source !== 'vm112-onboard') continue;
if (filter === 'wazuh' && source !== 'wazuh') continue;
if (filter === 'checks') continue;
const trailDomain = ev.domain || p.domain || '';
const trailDomainMeta = domainsFlat.find((item) => item.domain === trailDomain);
rows.push({
action: ev.event_type || 'event',
target: trailDomain || p.data?.agent || '—',
at: ev.created_at,
source,
tenant_id: trailDomainMeta?.tenant_id || (source === 'wazuh' ? 2 : 1),
funnel_stage: trailDomainMeta?.funnel_stage || '',
kind: 'webhook',
});
}
for (const d of domainsFlat) {
for (const issue of d.issues || []) {
if (!isInWindow(issue.checked_at, windowHours)) continue;
if (filter === 'onboard' || filter === 'wazuh') continue;
rows.push({
action: `check.${issue.status}`,
target: d.domain,
detail: `${issue.check_id}${issue.message || issue.status}`,
at: issue.checked_at,
source: 'audit',
tenant_id: d.tenant_id,
funnel_stage: d.funnel_stage || '',
kind: 'check',
domain: d.domain,
});
}
}
rows.sort((a, b) => new Date(b.at) - new Date(a.at));
return rows.slice(0, 40);
}
async function renderOverviewHome(options = {}) {
const el = document.getElementById('overview-home-content');
if (!el) return;
if (window.DeskServices?.renderPage) {
await window.DeskServices.renderPage(el, options);
return;
}
if (window.DeskAccounts?.renderPage) {
await window.DeskAccounts.renderPage(el, options);
return;
}
el.innerHTML = '<p class="loading">Módulo Serviços não carregado.</p>';
}
async function renderLeads() {
const el = document.getElementById('leads-content');
if (!canReadLeads()) {
el.innerHTML = '<p class="loading">Sem permissão para ver leads</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando leads…</p>';
try {
const data = await api('/v1/crm/leads');
const leads = data.leads || [];
el.innerHTML = `
<div class="card">
<div class="card-head-row">
<h3>Leads abandonados</h3>
<span class="ticket-meta">Stale ≥ ${data.stale_hours ?? 24}h sem concluir onboarding</span>
</div>
<p class="ticket-meta" style="margin-bottom:1rem">
Tickets promovidos automaticamente pelo worker quando o cliente para no funil.
Use o e-mail do ticket para recuperação (Spec 012 Fase C — chat).
</p>
${leads.length
? `<div class="lead-grid">${leads.map(leadRowHtml).join('')}</div>`
: '<p class="loading">Nenhum lead no momento</p>'}
</div>`;
el.querySelectorAll('[data-lead-ticket]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.leadTicket);
state.selectedSessionId = btn.dataset.leadSession || null;
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderTickets(options = {}) {
const { poll = false } = options;
stopLiveTimingClock();
const listEl = document.getElementById('ticket-list');
const detailEl = document.getElementById('ticket-detail');
if (poll && window.TicketsWorkspace?._pageReady) {
await TicketsWorkspace.softRefresh();
return;
}
listEl.innerHTML = '<p class="loading">Carregando tickets…</p>';
try {
let tickets = [];
if (state.ticketFilter === 'leads') {
const data = await api('/v1/crm/leads');
tickets = (data.leads || []).map((l) => ({
id: l.ticket_id,
subject: l.subject,
domain: l.domain,
email: l.email,
status: l.status,
created_at: l.created_at,
source: 'vm112-onboard',
crm_track: 'lead',
assigned_to: l.assigned_to,
session_id: l.session_id,
lead_funnel_stage: l.funnel_stage,
}));
} else {
let q = '';
const params = [];
if (state.ticketFilter !== 'all' && state.ticketFilter !== 'active') {
params.push(`status=${state.ticketFilter}`);
}
if (state.sourceFilter !== 'all') params.push(`source=${state.sourceFilter}`);
if (params.length) q = '?' + params.join('&');
const data = await api(`/v1/desk/tickets${q}`);
tickets = data.tickets || [];
if (state.ticketFilter === 'active') {
tickets = tickets.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status));
}
}
if (window.TicketsWorkspace) {
await TicketsWorkspace.renderPage({ listEl, detailEl, tickets });
} else {
state.tickets = tickets;
listEl.innerHTML = state.tickets.length
? state.tickets.map(ticketRowHtml).join('')
: '<p class="loading">Nenhum ticket neste filtro</p>';
listEl.querySelectorAll('.ticket-row').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.id);
state.selectedSessionId = null;
renderTicketDetail();
listEl.querySelectorAll('.ticket-row').forEach((r) => r.classList.remove('selected'));
btn.classList.add('selected');
});
});
if (state.selectedTicketId) await renderTicketDetail();
else if (state.selectedSessionId) await renderSessionDetail();
else detailEl.innerHTML = '<div class="card detail-panel"><p class="empty">Selecione um ticket ou sessão do funil</p></div>';
}
} catch (e) {
listEl.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderSessionDetail() {
const detailEl = document.getElementById('ticket-detail');
const sessionId = state.selectedSessionId;
if (!sessionId) return;
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando sessão…</p></div>';
try {
const meta = await loadAssistMeta(sessionId);
detailEl.innerHTML = `
<div class="card detail-panel">
<h3 style="margin:0">Sessão onboarding</h3>
<dl class="kv">
<dt>Domínio</dt><dd>${esc(meta.domain || '—')}</dd>
<dt>Etapa</dt><dd>${esc(FUNNEL_LABELS[meta.funnel_stage] || meta.funnel_stage || '—')}</dd>
<dt>Sessão</dt><dd><code>${esc(meta.session_id)}</code></dd>
${meta.ticket_id ? `<dt>Ticket</dt><dd>#${meta.ticket_id}</dd>` : ''}
</dl>
${assistActionsHtml(sessionId, {
can_escalate: meta.can_escalate,
assist_status: meta.ticket_status || meta.assist_status,
assisted_by: meta.assisted_by,
actions: meta.actions,
}, meta._console || {})}
${meta.timeline?.length ? `${phaseTimingCardHtml(meta.timing, meta.timeline)}<h3 style="margin-top:1.25rem">Eventos</h3>${timelineHtml(meta.timeline, meta.timing, { compact: true })}` : ''}
</div>`;
bindAssistActions(detailEl, sessionId);
bindLiveTimingClock(detailEl);
} catch (e) {
detailEl.innerHTML = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
}
}
async function renderTicketDetail() {
const detailEl = document.getElementById('ticket-detail');
if (!state.selectedTicketId) return;
if (window.TicketsDetailPanel) {
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
return;
}
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
try {
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
const sessionId = t.session_id || state.selectedSessionId;
const assistMeta = sessionId && t.source === 'vm112-onboard'
? await loadAssistMeta(sessionId)
: null;
if (sessionId) state.selectedSessionId = sessionId;
let carbonioBlock = null;
if (t.source === 'vm112-onboard' && window.DeskModules?.isEnabled('carbonio-release')) {
try {
const byTicket = await api(`/v1/carbonio-blocks?ticket_id=${t.id}&status=pending&limit=1`);
carbonioBlock = byTicket.blocks?.[0] || null;
if (!carbonioBlock && sessionId) {
const bySession = await api(`/v1/carbonio-blocks?session_id=${encodeURIComponent(sessionId)}&status=pending&limit=1`);
carbonioBlock = bySession.blocks?.[0] || null;
}
} catch {
carbonioBlock = null;
}
}
const timeline = assistMeta?.timeline?.length
? assistMeta.timeline
: (t.timeline || t.related_events || []);
const timing = assistMeta?.timing || t.timing;
const closeStatuses = ['open', 'escalated', 'assisting', 'resolved'];
detailEl.innerHTML = `
<div class="card detail-panel">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">
<h3 style="margin:0">Ticket #${t.id}</h3>
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
</div>
<dl class="kv">
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
<dt>Domínio/Agente</dt><dd>${esc(t.domain || t.agent || '—')}</dd>
<dt>Email</dt><dd>${esc(t.email || '—')}</dd>
<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>
${t.assigned_to ? `<dt>Atribuído</dt><dd>${esc(t.assigned_to)}</dd>` : ''}
${t.assisted_by ? `<dt>Assistido por</dt><dd>${esc(t.assisted_by)}</dd>` : ''}
${t.client_paused ? '<dt>Cliente</dt><dd><span class="badge assisting">pausado</span></dd>' : ''}
${t.ready_for_ops ? '<dt>Ops</dt><dd><span class="badge ok">ready for ops</span></dd>' : ''}
${t.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</dd>` : ''}
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
${t.desk_message ? `<dt>Nota</dt><dd>${esc(t.desk_message)}</dd>` : ''}
${t.registration_role ? `<dt>Perfil</dt><dd>${esc(roleLabel(t.registration_role))}</dd>` : ''}
${t.ativation_url ? `<dt>Ativar conta</dt><dd><a class="btn btn-primary btn-sm" href="${esc(t.ativation_url)}" target="_blank" rel="noopener">Abrir link de ativação</a></dd>` : ''}
<dt>Sessão/Alert ID</dt><dd><code>${esc(t.session_id || '—')}</code></dd>
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</dd>
<dt>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
</dl>
${sessionId && t.source === 'vm112-onboard' ? assistActionsHtml(sessionId, {
can_escalate: assistMeta?.can_escalate,
assist_status: assistMeta?.ticket_status || assistMeta?.assist_status,
assisted_by: assistMeta?.assisted_by,
actions: assistMeta?.actions,
}, assistMeta?._console || {}) : ''}
${carbonioBlock ? carbonioBlockPanelHtml(carbonioBlock) : ''}
<div class="actions">
${canPatchTickets() ? (closeStatuses.includes(t.status)
? `<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>`
: `<button type="button" class="btn btn-ghost" data-action="open">Reabrir ticket</button>`) : ''}
</div>
${timeline.length ? `${phaseTimingCardHtml(timing, timeline)}<h3 style="margin-top:1.25rem">Eventos</h3>${timelineHtml(timeline, timing, { compact: true })}` : ''}
<h3 style="margin-top:1.25rem">Payload</h3>
<pre class="raw">${esc(JSON.stringify(t.payload, null, 2))}</pre>
</div>`;
if (sessionId && t.source === 'vm112-onboard') {
bindAssistActions(detailEl, sessionId);
}
bindCarbonioResolveForms(detailEl);
bindLiveTimingClock(detailEl);
detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed'));
detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open'));
} catch (e) {
detailEl.innerHTML = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
}
}
async function updateTicketStatus(status) {
await api(`/v1/desk/tickets/${state.selectedTicketId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
await renderTickets();
}
async function renderEvents() {
syncEventsToolbar();
if (state.eventsTab === 'purges') {
await renderPurgeHistory();
return;
}
if (state.eventsTab === 'security') {
await renderSecurityEvents();
return;
}
if (state.eventsTab === 'carbonio') {
await renderCarbonioBlocks();
return;
}
const el = document.getElementById('events-content');
el.innerHTML = '<p class="loading">Carregando eventos…</p>';
try {
const srcQ = state.eventSourceFilter !== 'all' ? `?source=${state.eventSourceFilter}` : '';
const data = await api(`/v1/webhooks/events${srcQ}`);
const rows = (data.events || []).map((e) => {
const p = e.payload || {};
const dataObj = p.data || {};
return `<tr>
<td>${e.id}</td>
<td>${sourceBadge(e.source)}</td>
<td><span class="badge open">${esc(e.event_type)}</span> ${severityBadge(dataObj.level || e.severity)}</td>
<td>${esc(p.domain || '—')}</td>
<td><code>${esc((p.session_id || '').slice(0, 16))}</code></td>
<td>${fmtDate(e.created_at)}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card table-wrap">
<table>
<thead><tr><th>ID</th><th>Origem</th><th>Evento</th><th>Agente/Domínio</th><th>Ref</th><th>Data</th></tr></thead>
<tbody>${rows || '<tr><td colspan="6">Sem eventos</td></tr>'}</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function syncEventsToolbar() {
const isPurges = state.eventsTab === 'purges';
const isSecurity = state.eventsTab === 'security';
const isCarbonio = state.eventsTab === 'carbonio';
document.querySelectorAll('[data-events-tab]').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.eventsTab === state.eventsTab);
});
document.querySelectorAll('.events-webhooks-only').forEach((el) => {
el.hidden = isPurges || isSecurity || isCarbonio;
});
document.querySelectorAll('.events-security-only').forEach((el) => {
el.hidden = !isSecurity;
});
const title = document.getElementById('page-title');
const sub = document.getElementById('page-subtitle');
if (state.view === 'events' && title) {
const titles = {
purges: 'Histórico de purges',
security: 'Eventos de segurança wizard',
carbonio: 'Bloqueios Carbonio',
};
title.textContent = titles[state.eventsTab] || 'Eventos webhook';
if (sub) {
const subs = {
purges: 'Purges VM112 persistidos no Desk — timeline, usuário e serviços removidos',
security: 'CSP, inputs bloqueados e handoff — telemetria Spec 021',
carbonio: 'ACCOUNT_EXISTS — remover conta órfã no Carbonio para o cliente repetir o passo',
};
sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento';
}
}
}
function carbonioBlockStatusBadge(status) {
const map = {
pending: ['open', 'Pendente'],
resolved: ['done', 'Resolvido'],
};
const [cls, label] = map[status] || ['open', status || '—'];
return `<span class="badge ${cls}">${esc(label)}</span>`;
}
function carbonioReleaseGuideHtml() {
return `
<details class="wizard-sec-playbook" style="margin:0.75rem 0">
<summary>Guia — libertar e-mail ACCOUNT_EXISTS</summary>
<ol class="wizard-sec-steps">
<li>O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).</li>
<li>Confirme o e-mail exacto e a sua <strong>senha Desk</strong> (não a do Carbonio nem root).</li>
<li>A ação remove apenas a conta Carbonio (<code>zmprov da</code>) — domínio, DNS e portal mantêm-se.</li>
<li>Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.</li>
<li>Dois técnicos a resolver em paralelo: só o primeiro consegue; o outro vê «já resolvido».</li>
</ol>
</details>`;
}
function carbonioResolveFormHtml(block) {
if (block.status === 'resolved') {
return `<p class="ticket-meta">Resolvido por <strong>${esc(block.resolved_by)}</strong> em ${fmtDate(block.resolved_at)}${block.resolution_note ? `${esc(block.resolution_note)}` : ''}</p>`;
}
if (!canReadTickets()) return '';
return `
<form class="carbonio-resolve-form" data-carbonio-block="${block.id}" style="margin-top:0.75rem">
<p class="ticket-meta">Confirme o e-mail e a sua senha Desk para executar <code>zmprov da</code> na VM112.</p>
<div style="display:grid;gap:0.5rem;max-width:24rem">
<label>E-mail a libertar
<input type="email" name="confirm_email" required placeholder="${esc(block.email)}" autocomplete="off" class="input">
</label>
<label>Sua senha Desk
<input type="password" name="password" required autocomplete="current-password" class="input">
</label>
<button type="submit" class="btn btn-primary btn-sm">Remover conta Carbonio</button>
</div>
<p class="carbonio-resolve-msg ticket-meta" hidden style="margin-top:0.5rem"></p>
</form>`;
}
function carbonioBlockPanelHtml(block) {
return `
<div class="card carbonio-release-card" style="margin-top:1rem;border-left:4px solid #e67e22">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">
<h3 style="margin:0">Bloqueio Carbonio — ACCOUNT_EXISTS</h3>
${carbonioBlockStatusBadge(block.status)}
</div>
<p class="ticket-meta" style="margin:0.5rem 0">
E-mail <code>${esc(block.email)}</code> · domínio ${esc(block.domain)}
${block.ticket_id ? ` · bloqueio #${block.id}` : ''}
</p>
${block.error_message ? `<p class="ticket-meta" style="opacity:0.85">${esc(block.error_message.slice(0, 240))}</p>` : ''}
${carbonioReleaseGuideHtml()}
${carbonioResolveFormHtml(block)}
</div>`;
}
async function resolveCarbonioBlock(blockId, confirmEmail, password) {
return api(`/v1/carbonio-blocks/${blockId}/resolve`, {
method: 'POST',
body: JSON.stringify({ confirm_email: confirmEmail, password }),
});
}
function bindCarbonioResolveForms(root) {
root.querySelectorAll('.carbonio-resolve-form').forEach((form) => {
if (form.dataset.bound) return;
form.dataset.bound = '1';
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const blockId = form.dataset.carbonioBlock;
const fd = new FormData(form);
const msgEl = form.querySelector('.carbonio-resolve-msg');
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
msgEl.hidden = true;
try {
const res = await resolveCarbonioBlock(blockId, fd.get('confirm_email'), fd.get('password'));
msgEl.textContent = res.message || 'Conta removida do Carbonio.';
msgEl.style.color = 'var(--ok, #2ecc71)';
msgEl.hidden = false;
setTimeout(async () => {
if (state.view === 'events') await renderEvents();
else if (state.selectedTicketId) await renderTicketDetail();
}, 1200);
} catch (e) {
msgEl.textContent = e.message;
msgEl.style.color = 'var(--danger, #e74c3c)';
msgEl.hidden = false;
btn.disabled = false;
}
});
});
}
async function renderCarbonioBlocks() {
syncEventsToolbar();
const el = document.getElementById('events-content');
if (!window.DeskModules?.isEnabled('carbonio-release')) {
el.innerHTML = '<p class="loading">Módulo Bloqueios Carbonio desativado.</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando bloqueios Carbonio…</p>';
try {
const [pending, resolved] = await Promise.all([
api('/v1/carbonio-blocks?status=pending&limit=100'),
api('/v1/carbonio-blocks?status=resolved&limit=30'),
]);
const pendingBlocks = pending.blocks || [];
const resolvedBlocks = resolved.blocks || [];
const pendingCards = pendingBlocks.length
? pendingBlocks.map((b) => carbonioBlockPanelHtml(b)).join('')
: '<p class="ticket-meta">Nenhum bloqueio pendente — novos casos aparecem aqui via webhook <code>onboarding.failed</code> + ACCOUNT_EXISTS.</p>';
const resolvedRows = resolvedBlocks.map((b) => `
<tr>
<td>#${b.id}</td>
<td><code>${esc(b.email)}</code></td>
<td>${esc(b.domain)}</td>
<td>${esc(b.resolved_by || '—')}</td>
<td>${fmtDate(b.resolved_at)}</td>
<td>${b.ticket_id ? `#${b.ticket_id}` : '—'}</td>
</tr>`).join('');
el.innerHTML = `
<div class="card" style="margin-bottom:1rem">
<p class="ticket-meta" style="margin:0">
<strong>${pending.total || pendingBlocks.length}</strong> pendente(s) ·
<strong>${resolved.total || resolvedBlocks.length}</strong> resolvido(s) recentes
</p>
</div>
${pendingCards}
<details class="card" style="margin-top:1rem">
<summary style="cursor:pointer;font-weight:600">Histórico resolvido (${resolvedBlocks.length})</summary>
<div class="table-wrap" style="margin-top:0.75rem">
<table>
<thead><tr><th>ID</th><th>E-mail</th><th>Domínio</th><th>Resolvido por</th><th>Quando</th><th>Ticket</th></tr></thead>
<tbody>${resolvedRows || '<tr><td colspan="6">Nenhum</td></tr>'}</tbody>
</table>
</div>
</details>`;
bindCarbonioResolveForms(el);
el.querySelectorAll('[data-goto-ticket]').forEach((link) => {
link.addEventListener('click', (ev) => {
ev.preventDefault();
state.selectedTicketId = Number(link.dataset.gotoTicket);
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderSecurityEvents() {
syncEventsToolbar();
const el = document.getElementById('events-content');
if (!window.DeskModules?.isEnabled('wizard-security')) {
el.innerHTML = '<p class="loading">Módulo Segurança Wizard desativado.</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando eventos de segurança…</p>';
try {
const [data, summary] = await Promise.all([
api('/v1/security/events?limit=200&window_hours=168'),
api('/v1/security/summary?window_hours=24').catch(() => ({})),
]);
const rows = (data.events || []).map((ev) => `
<tr class="wizard-sec-row" data-wizard-sec-session="${esc(ev.session_id || '')}" style="cursor:pointer">
<td>${wizardSecuritySeverityBadge(ev.severity)}</td>
<td>${esc(wizardSecurityEventLabel(ev.event_type))}</td>
<td>${ev.session_id ? sessionHashHtml(ev.session_id) : '—'}</td>
<td>${esc(ev.domain || '—')}</td>
<td><code>${esc(ev.client_ip || '—')}</code></td>
<td>${esc(ev.endpoint || ev.reason || '—')}</td>
<td>${fmtDate(ev.created_at)}</td>
</tr>`).join('');
el.innerHTML = `
<div class="card">
<p class="ticket-meta" style="margin:0 0 0.75rem">
Últimas 24h: <strong>${summary.csp_violations || 0}</strong> CSP ·
<strong>${summary.inputs_blocked || 0}</strong> bloqueados ·
<strong>${summary.handoffs_rejected || 0}</strong> handoffs rejeitados
</p>
<details class="wizard-sec-playbook"><summary>Guia rápido para técnicos</summary>
<ol class="wizard-sec-steps">
<li>Input bloqueado → anote hash + IP; se repetido, escale.</li>
<li>Handoff rejeitado → cliente deve refazer login; ticket escalado automático.</li>
<li>Clique na linha para abrir a sessão em Tickets.</li>
</ol>
</details>
<div class="table-wrap" style="margin-top:0.75rem">
<table class="wizard-sec-table">
<thead><tr><th>Nível</th><th>Evento</th><th>Sessão</th><th>Domínio</th><th>IP</th><th>Detalhe</th><th>Quando</th></tr></thead>
<tbody>${rows || '<tr><td colspan="7">Nenhum evento de segurança</td></tr>'}</tbody>
</table>
</div>
</div>`;
el.querySelectorAll('[data-wizard-sec-session]').forEach((row) => {
const sid = row.dataset.wizardSecSession;
if (!sid) return;
row.addEventListener('click', () => {
state.selectedSessionId = sid;
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function purgeStatusBadge(status) {
const map = {
done: ['done', 'Concluído'],
error: ['closed', 'Erro'],
running: ['open', 'Em execução'],
queued: ['pending', 'Na fila'],
};
const [cls, label] = map[status] || ['open', status || '—'];
return `<span class="badge ${cls}">${esc(label)}</span>`;
}
function deskRemovedSummary(desk) {
if (!desk || typeof desk !== 'object') return '—';
const labels = {
webhook_events: 'webhooks',
tickets: 'tickets',
audit_domains: 'audit',
assist_sessions: 'assist',
audit_checks: 'checks',
};
const parts = Object.entries(desk)
.filter(([, n]) => Number(n) > 0)
.map(([k, n]) => `${labels[k] || k}: ${n}`);
return parts.length ? parts.join(', ') : 'nenhum no Desk';
}
function vm112RemovedSummary(vm112) {
if (!vm112 || !vm112.ok) return vm112?.error ? esc(vm112.error) : '—';
const r = vm112.result || {};
const parts = [];
if (Array.isArray(r.carbonio_accounts) && r.carbonio_accounts.length) {
parts.push(`Carbonio (${r.carbonio_accounts.length} contas)`);
} else if (r.carbonio_domain) {
parts.push('Carbonio');
}
if (Array.isArray(r.portal_users_removed) && r.portal_users_removed.length) {
parts.push(`portal (${r.portal_users_removed.length})`);
}
if (r.site_folder_removed) parts.push('site');
if (r.cloudflare) parts.push('Cloudflare');
if (r.traefik_sni || r.traefik_routers) parts.push('Traefik');
return parts.length ? esc(parts.join(', ')) : 'VM112 OK';
}
function renderPurgeTimelineHtml(steps) {
return `<ul class="vm112-purge-timeline purge-history-timeline">${(steps || []).map((step) => {
const status = step.status || 'pending';
return `
<li class="vm112-purge-step vm112-purge-step--${esc(status)}">
<span class="vm112-purge-step-time">${esc(fmtDate(step.at))}</span>
<div class="vm112-purge-step-body">
<strong>${esc(step.label)}</strong>
${step.detail ? `<span>${esc(step.detail)}</span>` : ''}
</div>
</li>`;
}).join('')}</ul>`;
}
function closePurgeHistoryModal() {
const modal = document.getElementById('purge-history-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function openPurgeHistoryModal(jobId) {
const modal = document.getElementById('purge-history-modal');
const title = document.getElementById('purge-history-modal-title');
const sub = document.getElementById('purge-history-modal-sub');
const body = document.getElementById('purge-history-modal-body');
if (!modal || !body) return;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
title.textContent = 'Detalhe do purge';
sub.textContent = `Job ${jobId}`;
body.innerHTML = '<p class="loading">Carregando…</p>';
api(`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}`)
.then((job) => {
title.textContent = job.domain || 'Purge';
sub.innerHTML = `${purgeStatusBadge(job.status)} · ${esc(job.by || '—')} · ${fmtDate(job.created_at)} · job <code>${esc(job.id)}</code>`;
const desk = job.desk || {};
const vm112 = job.vm112 || {};
const deskRows = Object.entries({
webhook_events: 'Eventos webhook',
tickets: 'Tickets',
audit_domains: 'Domínios audit',
assist_sessions: 'Sessões assist',
audit_checks: 'Checks audit',
}).map(([key, label]) => `
<tr><td>${esc(label)}</td><td>${Number(desk[key] || 0)}</td></tr>`).join('');
const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : [];
const timeline = (job.timeline || []).length ? job.timeline : vm112Steps;
body.innerHTML = `
<div class="purge-history-grid">
<div class="card">
<h4>Removido no Desk (VM122)</h4>
<table class="purge-history-kv">
<tbody>${deskRows}</tbody>
<tfoot><tr><td><strong>Total</strong></td><td><strong>${Object.values(desk).reduce((a, b) => a + Number(b || 0), 0)}</strong></td></tr></tfoot>
</table>
</div>
<div class="card">
<h4>Removido na VM112</h4>
<p class="purge-history-vm112-sum">${vm112RemovedSummary(vm112)}</p>
${job.elapsed_vm112 ? `<p class="ticket-meta">Duração VM112: ${job.elapsed_vm112}s</p>` : ''}
${job.error ? `<p class="purge-history-error">${esc(job.error)}</p>` : ''}
</div>
</div>
<div class="card" style="margin-top:1rem">
<h4>Timeline completa</h4>
${timeline.length ? renderPurgeTimelineHtml(timeline) : '<p class="loading">Sem passos registados</p>'}
</div>`;
})
.catch((e) => {
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
});
document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
el.onclick = closePurgeHistoryModal;
});
}
async function renderPurgeHistory() {
syncEventsToolbar();
const el = document.getElementById('events-content');
el.innerHTML = '<p class="loading">Carregando histórico de purges…</p>';
try {
const data = await api('/v1/vm112/purge/jobs?limit=200');
const rows = (data.jobs || []).map((j) => `
<tr class="purge-history-row" data-purge-job="${esc(j.id)}" tabindex="0" role="button">
<td><code class="purge-history-link">${esc(j.id)}</code></td>
<td><strong>${esc(j.domain)}</strong></td>
<td>${purgeStatusBadge(j.status)}</td>
<td>${esc(j.by || '—')}</td>
<td class="purge-history-removed">${esc(deskRemovedSummary(j.desk))}</td>
<td>${fmtDate(j.created_at)}</td>
<td>${j.elapsed_vm112 ? `${j.elapsed_vm112}s` : '—'}</td>
</tr>`).join('');
el.innerHTML = `
<div class="card table-wrap">
<p class="ticket-meta" style="padding:0.75rem 1rem 0">Clique numa linha para ver a timeline completa e o que foi removido em cada serviço.</p>
<table class="purge-history-table">
<thead>
<tr>
<th>Job</th><th>Domínio</th><th>Status</th><th>Usuário</th>
<th>Desk removido</th><th>Quando</th><th>VM112</th>
</tr>
</thead>
<tbody>${rows || '<tr><td colspan="7">Nenhum purge registado</td></tr>'}</tbody>
</table>
${data.total > (data.jobs || []).length ? `<p class="ticket-meta" style="padding:0.5rem 1rem">A mostrar ${(data.jobs || []).length} de ${data.total} purges.</p>` : ''}
</div>`;
el.querySelectorAll('[data-purge-job]').forEach((row) => {
const open = () => openPurgeHistoryModal(row.dataset.purgeJob);
row.addEventListener('click', open);
row.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
open();
}
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderTenants() {
const el = document.getElementById('tenants-content');
el.innerHTML = '<p class="loading">Carregando…</p>';
try {
const data = await api('/v1/tenants');
el.innerHTML = `
<div class="card table-wrap">
<table>
<thead><tr><th>ID</th><th>Nome</th><th>IP</th><th>Papel</th><th>Desde</th></tr></thead>
<tbody>${(data.tenants || []).map((t) => `
<tr>
<td>${t.id}</td>
<td>${esc(t.name)}</td>
<td><code>${esc(t.ip)}</code></td>
<td>${esc(t.role)}</td>
<td>${fmtDate(t.created_at)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function fmtRelative(iso) {
if (!iso) return 'nunca';
const diff = Date.now() - new Date(iso).getTime();
if (Number.isNaN(diff)) return '—';
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'agora';
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
if (days === 1) return 'ontem';
if (days < 7) return `${days} dias`;
return fmtDate(iso);
}
function userInitials(displayName, username) {
const src = (displayName || username || '?').trim();
const parts = src.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
if (src.includes('@')) return src[0].toUpperCase();
return src.slice(0, 2).toUpperCase();
}
function roleBadgeHtml(role) {
const cls = {
super_admin: 'role-super',
ops_lead: 'role-lead',
technician: 'role-tech',
noc: 'role-noc',
sales_admin: 'role-sales-admin',
sales_support: 'role-sales-support',
finance: 'role-finance',
marketing: 'role-marketing',
seo: 'role-seo',
developer: 'role-developer',
devops: 'role-devops',
security_analyst: 'role-security',
content_editor: 'role-content',
agentic_operator: 'role-agentic',
}[role] || 'role-default';
return `<span class="role-badge ${cls}">${esc(roleLabel(role))}</span>`;
}
function mfaBadgeHtml(user) {
if (user.totp_enabled) {
const backups = Number(user.backup_codes_remaining || 0);
const hint = backups > 0 ? ` · ${backups} backup` : '';
return `<span class="badge ok">2FA${hint}</span>`;
}
return '<span class="badge review">sem 2FA</span>';
}
const ROLE_OPTIONS = [
{ value: 'super_admin', label: 'Super Admin', group: 'Ops' },
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops' },
{ value: 'technician', label: 'Suporte', group: 'Ops' },
{ value: 'noc', label: 'NOC', group: 'Ops' },
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial' },
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial' },
{ value: 'finance', label: 'Financeiro', group: 'Negócio' },
{ value: 'marketing', label: 'Marketing', group: 'Negócio' },
{ value: 'seo', label: 'SEO', group: 'Negócio' },
{ value: 'developer', label: 'Developer', group: 'Plataforma' },
{ value: 'devops', label: 'DevOps', group: 'Plataforma' },
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma' },
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma' },
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma' },
];
const ASSIGNABLE_ROLE_OPTIONS = ROLE_OPTIONS.filter((r) => r.value !== 'super_admin');
function registrationRoleSelectHtml(selected = 'technician') {
const groups = [...new Set(ASSIGNABLE_ROLE_OPTIONS.map((r) => r.group))];
return groups.map((group) => {
const opts = ASSIGNABLE_ROLE_OPTIONS.filter((r) => r.group === group)
.map((r) => `<option value="${r.value}" ${r.value === selected ? 'selected' : ''}>${esc(r.label)}</option>`)
.join('');
return `<optgroup label="${esc(group)}">${opts}</optgroup>`;
}).join('');
}
function roleSelectHtml(username, current, assignableOnly = true) {
const options = assignableOnly && current !== 'super_admin'
? ASSIGNABLE_ROLE_OPTIONS
: ROLE_OPTIONS;
const opts = options.map((r) =>
`<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`
).join('');
return `<select class="admin-role" data-user="${esc(username)}">${opts}</select>`;
}
async function saveUser(username, payload, msgEl) {
if (msgEl) msgEl.textContent = 'Salvando…';
try {
await api(`/v1/auth/users/${encodeURIComponent(username)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
if (msgEl) {
msgEl.textContent = 'Salvo';
msgEl.className = 'admin-msg ok';
}
closeTeamDrawer();
await renderAdmin();
} catch (e) {
if (msgEl) {
msgEl.textContent = e.message;
msgEl.className = 'admin-msg err';
}
throw e;
}
}
function filterAdminUsers(users) {
const { q, role, status, mfa } = state.adminFilter;
const query = (q || '').trim().toLowerCase();
return users.filter((u) => {
if (role !== 'all' && u.role !== role) return false;
if (status === 'active' && !u.active) return false;
if (status === 'inactive' && u.active) return false;
if (mfa === 'on' && !u.totp_enabled) return false;
if (mfa === 'off' && u.totp_enabled) return false;
if (!query) return true;
const hay = [
u.username,
u.email,
u.display_name,
roleLabel(u.role),
].join(' ').toLowerCase();
return hay.includes(query);
});
}
function closeTeamDrawer() {
const drawer = document.getElementById('team-drawer');
if (!drawer) return;
drawer.classList.add('hidden');
drawer.setAttribute('aria-hidden', 'true');
state.adminSelected = null;
}
function bindTeamDrawerClose() {
document.querySelectorAll('[data-close-team-drawer]').forEach((el) => {
el.onclick = closeTeamDrawer;
});
}
function openTeamDrawer(username) {
const user = state.adminUsers.find((u) => u.username === username);
if (!user) return;
state.adminSelected = username;
const drawer = document.getElementById('team-drawer');
const body = document.getElementById('team-drawer-body');
const title = document.getElementById('team-drawer-title');
if (!drawer || !body) return;
const email = user.email || (user.username.includes('@') ? user.username : '—');
const isRoot = user.username === 'root';
title.textContent = user.display_name || user.username;
body.innerHTML = `
<div class="team-drawer-profile">
<div class="team-avatar team-avatar-lg" aria-hidden="true">${esc(userInitials(user.display_name, user.username))}</div>
<div>
<p class="team-drawer-name">${esc(user.display_name || user.username)}</p>
<p class="ticket-meta">${esc(email)}</p>
<p class="ticket-meta"><code>${esc(user.username)}</code></p>
</div>
</div>
<dl class="kv team-drawer-meta">
<dt>Criado</dt><dd>${fmtDate(user.created_at)}</dd>
<dt>Último login</dt><dd>${fmtRelative(user.last_login_at)}</dd>
<dt>Segurança</dt><dd>${mfaBadgeHtml(user)}</dd>
</dl>
<form id="team-drawer-form" class="team-drawer-form" autocomplete="off">
<label>Nome de exibição
<input type="text" id="team-drawer-display" value="${esc(user.display_name || '')}" placeholder="Nome"/>
</label>
<label>Perfil
${roleSelectHtml(user.username, user.role, !isRoot)}
</label>
<label>Estado da conta
<select id="team-drawer-active" ${isRoot ? 'disabled' : ''}>
<option value="1" ${user.active ? 'selected' : ''}>ativo</option>
<option value="0" ${!user.active ? 'selected' : ''}>inativo</option>
</select>
</label>
<label>Nova senha
<input type="password" id="team-drawer-password" placeholder="opcional (mín. 6 caracteres)" minlength="6"/>
</label>
${user.totp_enabled ? `
<div class="team-drawer-danger">
<p class="ticket-meta">2FA ativo — o usuário pode recuperar no login ou você pode resetar aqui.</p>
<button type="button" class="btn btn-ghost btn-sm" id="team-reset-2fa">Resetar 2FA</button>
</div>` : ''}
<p id="team-drawer-msg" class="admin-msg" hidden></p>
<div class="team-drawer-actions">
<button type="submit" class="btn btn-primary">Salvar alterações</button>
<button type="button" class="btn btn-ghost" data-close-team-drawer>Cancelar</button>
</div>
</form>`;
drawer.classList.remove('hidden');
drawer.setAttribute('aria-hidden', 'false');
bindTeamDrawerClose();
body.querySelector('#team-drawer-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const msgEl = body.querySelector('#team-drawer-msg');
msgEl.hidden = false;
const payload = {
display_name: body.querySelector('#team-drawer-display')?.value?.trim() || null,
role: body.querySelector('.admin-role')?.value,
active: body.querySelector('#team-drawer-active')?.value === '1',
};
const pwd = body.querySelector('#team-drawer-password')?.value;
if (pwd && pwd.length >= 6) payload.password = pwd;
try {
await saveUser(user.username, payload, msgEl);
} catch {
/* msg shown */
}
});
body.querySelector('#team-reset-2fa')?.addEventListener('click', async () => {
const msgEl = body.querySelector('#team-drawer-msg');
if (!window.confirm(`Resetar 2FA de ${user.username}? O usuário entrará só com senha até reconfigurar.`)) return;
msgEl.hidden = false;
msgEl.textContent = 'Resetando…';
msgEl.className = 'admin-msg';
try {
await api(`/v1/auth/users/${encodeURIComponent(user.username)}/reset-2fa`, { method: 'POST' });
msgEl.textContent = '2FA resetado';
msgEl.className = 'admin-msg ok';
closeTeamDrawer();
await renderAdmin();
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'admin-msg err';
}
});
}
async function renderAdmin() {
const el = document.getElementById('admin-content');
if (!canManageUsers()) {
el.innerHTML = '<p class="loading">Sem permissão</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando equipe…</p>';
try {
const [usersData, regData] = await Promise.all([
api('/v1/auth/users'),
api('/v1/auth/registration-requests').catch(() => ({ pending_count: 0 })),
]);
state.adminUsers = usersData.users || [];
const users = state.adminUsers;
const filtered = filterAdminUsers(users);
const activeCount = users.filter((u) => u.active).length;
const mfaCount = users.filter((u) => u.totp_enabled).length;
const inactiveCount = users.length - activeCount;
const pending = regData.pending_count || 0;
const { q, role, status, mfa } = state.adminFilter;
const rows = filtered.map((u) => `
<tr class="team-row" data-user="${esc(u.username)}" tabindex="0">
<td>
<div class="team-user-cell">
<div class="team-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
<div>
<strong class="team-user-name">${esc(u.display_name || u.username)}</strong>
<span class="team-user-email">${esc(u.email || u.username)}</span>
</div>
</div>
</td>
<td>${roleBadgeHtml(u.role)}</td>
<td>${mfaBadgeHtml(u)}</td>
<td class="team-muted">${fmtRelative(u.last_login_at)}</td>
<td>${u.active ? '<span class="badge ok">ativo</span>' : '<span class="badge closed">inativo</span>'}</td>
<td class="team-actions">
<button type="button" class="btn btn-ghost btn-sm team-edit-btn" data-user="${esc(u.username)}">Editar</button>
</td>
</tr>`).join('');
el.innerHTML = `
<div class="team-admin">
<header class="team-admin-head">
<div>
<h2 class="team-admin-title">Equipe Ligbox</h2>
<p class="ticket-meta">Gestão de acessos ao Support Desk</p>
</div>
<button type="button" class="btn btn-ghost btn-sm" id="team-goto-messages">
Pedidos de cadastro${pending ? ` <span class="badge review">${pending}</span>` : ''}
</button>
</header>
<div class="team-kpi-grid">
<div class="team-kpi card"><span class="team-kpi-val">${users.length}</span><span class="team-kpi-label">membros</span></div>
<div class="team-kpi card"><span class="team-kpi-val">${activeCount}</span><span class="team-kpi-label">ativos</span></div>
<div class="team-kpi card"><span class="team-kpi-val">${mfaCount}</span><span class="team-kpi-label">com 2FA</span></div>
<div class="team-kpi card"><span class="team-kpi-val">${inactiveCount}</span><span class="team-kpi-label">inativos</span></div>
</div>
<div class="team-toolbar card">
<label class="team-search">
<span class="sr-only">Buscar</span>
<input type="search" id="team-filter-q" placeholder="Buscar nome, e-mail ou perfil…" value="${esc(q)}"/>
</label>
<label>Perfil
<select id="team-filter-role">
<option value="all" ${role === 'all' ? 'selected' : ''}>Todos</option>
${ROLE_OPTIONS.map((r) => `<option value="${r.value}" ${role === r.value ? 'selected' : ''}>${r.label}</option>`).join('')}
</select>
</label>
<label>Estado
<select id="team-filter-status">
<option value="all" ${status === 'all' ? 'selected' : ''}>Todos</option>
<option value="active" ${status === 'active' ? 'selected' : ''}>Ativos</option>
<option value="inactive" ${status === 'inactive' ? 'selected' : ''}>Inativos</option>
</select>
</label>
<label>2FA
<select id="team-filter-mfa">
<option value="all" ${mfa === 'all' ? 'selected' : ''}>Todos</option>
<option value="on" ${mfa === 'on' ? 'selected' : ''}>Com 2FA</option>
<option value="off" ${mfa === 'off' ? 'selected' : ''}>Sem 2FA</option>
</select>
</label>
</div>
<div class="card team-table-wrap">
<table class="data-table team-table">
<thead>
<tr>
<th>Membro</th>
<th>Perfil</th>
<th>Segurança</th>
<th>Último login</th>
<th>Estado</th>
<th></th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="6" class="loading">Nenhum membro encontrado</td></tr>'}
</tbody>
</table>
<p class="team-table-foot ticket-meta">${filtered.length} de ${users.length} membros</p>
</div>
</div>`;
const applyFilters = () => {
state.adminFilter = {
q: document.getElementById('team-filter-q')?.value || '',
role: document.getElementById('team-filter-role')?.value || 'all',
status: document.getElementById('team-filter-status')?.value || 'all',
mfa: document.getElementById('team-filter-mfa')?.value || 'all',
};
renderAdmin();
};
document.getElementById('team-filter-q')?.addEventListener('input', () => {
clearTimeout(state._teamSearchTimer);
state._teamSearchTimer = setTimeout(applyFilters, 200);
});
['team-filter-role', 'team-filter-status', 'team-filter-mfa'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', applyFilters);
});
document.getElementById('team-goto-messages')?.addEventListener('click', () => setView('messages'));
el.querySelectorAll('.team-edit-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
openTeamDrawer(btn.dataset.user);
});
});
el.querySelectorAll('.team-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
openTeamDrawer(row.dataset.user);
});
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamDrawer(row.dataset.user);
}
});
});
if (state.adminSelected) {
openTeamDrawer(state.adminSelected);
}
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderModules() {
const el = document.getElementById('modules-content');
if (!el) return;
const user = getUser();
if (user?.role !== 'super_admin') {
el.innerHTML = '<p class="loading">Apenas Super Admin pode gerenciar módulos.</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando módulos…</p>';
try {
await DeskModules.load();
const mods = DeskModules.list;
el.innerHTML = `
<div class="card modules-admin-card">
<h3 style="margin-top:0">Módulos do Desk</h3>
<p class="ticket-meta">Desativar um módulo remove-o do menu e desliga enriquecimentos na API — o núcleo continua estável.</p>
<div class="modules-grid">
${mods.map((m) => `
<label class="module-row${m.locked ? ' module-row--locked' : ''}">
<div class="module-row-main">
<strong>${esc(m.label)}</strong>
<span class="ticket-meta">${esc(m.description)}</span>
<code class="module-id">${esc(m.id)}</code>
${m.locked ? '<span class="badge open">núcleo</span>' : ''}
</div>
<input type="checkbox" data-module-toggle="${esc(m.id)}" ${m.enabled ? 'checked' : ''} ${m.locked ? 'disabled' : ''} aria-label="Activar ${esc(m.label)}" />
</label>`).join('')}
</div>
</div>`;
el.querySelectorAll('[data-module-toggle]').forEach((input) => {
input.addEventListener('change', async () => {
const id = input.dataset.moduleToggle;
input.disabled = true;
try {
await api(`/v1/modules/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: input.checked }),
});
await DeskModules.load();
applyRoleNav();
DeskModules.applyVisibility();
if (!DeskModules.isViewEnabled(state.view)) setView('dashboard');
else refresh();
} catch (e) {
input.checked = !input.checked;
alert(e.message || 'Falha ao actualizar módulo');
} finally {
input.disabled = false;
}
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
const REG_ROLE_LABELS = ROLE_LABELS;
async function renderMessages() {
const el = document.getElementById('messages-content');
if (!canManageUsers()) {
el.innerHTML = '<p class="loading">Sem permissão</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando pedidos…</p>';
try {
const data = await api('/v1/auth/registration-requests');
const items = data.requests || [];
const pending = items.filter((r) => r.status === 'pending');
const history = items.filter((r) => r.status !== 'pending');
const pendingCards = pending.map((r) => `
<div class="card admin-user-card" data-req="${r.id}">
<div class="health-card-head">
<div>
<h3 style="margin:0">${esc(r.email)}</h3>
<p class="ticket-meta">${esc(r.display_name || '—')} · ${fmtDate(r.created_at)}</p>
</div>
<span class="badge review">pendente</span>
</div>
<label>Perfil a atribuir
<select class="req-role">
${registrationRoleSelectHtml('technician')}
</select>
</label>
<div class="actions" style="margin-top:0.75rem">
<button type="button" class="btn btn-primary req-approve">Aprovar</button>
<button type="button" class="btn btn-ghost req-reject">Rejeitar</button>
<span class="admin-msg"></span>
</div>
</div>`).join('');
const historyRows = history.map((r) => `
<tr>
<td>${esc(r.email)}</td>
<td><span class="badge ${r.status === 'active' ? 'ok' : r.status === 'rejected' ? 'closed' : 'review'}">${esc(statusLabel(r.status))}</span></td>
<td>${esc(r.role ? roleLabel(r.role) : '—')}</td>
<td>${fmtDate(r.updated_at || r.created_at)}</td>
</tr>`).join('');
el.innerHTML = `
<div class="admin-users">
<h3 style="margin:0 0 0.5rem">Pedidos pendentes <span class="ticket-meta">(${pending.length})</span></h3>
${pendingCards || '<p class="loading">Nenhum pedido pendente</p>'}
${history.length ? `
<h3 style="margin:1.5rem 0 0.5rem">Histórico</h3>
<div class="card" style="overflow:auto">
<table class="data-table">
<thead><tr><th>E-mail</th><th>Estado</th><th>Perfil</th><th>Atualizado</th></tr></thead>
<tbody>${historyRows}</tbody>
</table>
</div>` : ''}
</div>`;
el.querySelectorAll('[data-req]').forEach((card) => {
const id = card.dataset.req;
const msgEl = card.querySelector('.admin-msg');
card.querySelector('.req-approve')?.addEventListener('click', async () => {
msgEl.textContent = '…';
try {
const role = card.querySelector('.req-role')?.value;
await api(`/v1/auth/registration-requests/${id}/approve`, { method: 'POST', body: JSON.stringify({ role }) });
msgEl.textContent = 'Aprovado — email enviado';
msgEl.className = 'admin-msg ok';
await renderMessages();
} catch (e) {
msgEl.textContent = e.message;
msgEl.className = 'admin-msg err';
}
});
card.querySelector('.req-reject')?.addEventListener('click', async () => {
const reason = window.prompt('Motivo da rejeição (opcional):') || '';
msgEl.textContent = '…';
try {
await api(`/v1/auth/registration-requests/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason || null }),
});
msgEl.textContent = 'Rejeitado';
msgEl.className = 'admin-msg ok';
await renderMessages();
} catch (e) {
msgEl.textContent = e.message;
msgEl.className = 'admin-msg err';
}
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderAccount(force = false) {
const el = document.getElementById('account-content');
if (state.accountLoaded && !force) {
return;
}
const saved = force ? null : readAccountPwdForm();
el.innerHTML = '<p class="loading">Carregando…</p>';
try {
const me = await api('/v1/auth/me');
const totpOn = Boolean(me.totp_enabled || me.mfa_enabled);
el.innerHTML = `
<div class="card account-card" style="max-width:480px">
<h3 style="margin:0 0 0.75rem">Minha conta</h3>
<dl class="kv account-kv">
<dt>E-mail / login</dt><dd><code>${esc(me.email || me.username)}</code></dd>
<dt>Perfil</dt><dd>${esc(roleLabel(me.role))}</dd>
<dt>Nome</dt><dd>${esc(me.display_name || '—')}</dd>
<dt>Último login</dt><dd>${fmtDate(me.last_login_at)}</dd>
<dt>2FA (app)</dt><dd>${totpOn ? '<span class="badge ok">ativo</span>' : '<span class="badge review">não configurado</span>'}</dd>
${totpOn ? `<dt>Códigos backup</dt><dd>${Number(me.backup_codes_remaining || 0)} restante(s)</dd>` : ''}
</dl>
<hr style="border:none;border-top:1px solid var(--border);margin:1.25rem 0"/>
<h4 style="margin:0 0 0.5rem">Alterar senha</h4>
<p class="ticket-meta" style="margin:0 0 1rem">
${totpOn
? 'Por segurança, confirme a senha atual e o código do autenticador (sessão aberta). Se perdeu o app, use <a href="/login.html">recuperação no login</a> ou um código de backup.'
: 'Informe a senha atual e escolha uma nova (mín. 8 caracteres).'}
</p>
<form id="account-pwd-form" class="account-pwd-form" autocomplete="off">
<label for="acct-pwd-current">Senha atual
<input type="password" id="acct-pwd-current" name="current_password" autocomplete="current-password" required/>
</label>
<label for="acct-pwd-new">Nova senha
<input type="password" id="acct-pwd-new" name="new_password" autocomplete="new-password" required minlength="8"/>
</label>
<label for="acct-pwd-new2">Confirmar nova senha
<input type="password" id="acct-pwd-new2" name="new_password2" autocomplete="off" required minlength="8"/>
</label>
${totpOn ? `
<label for="acct-pwd-totp">Código 2FA (app)
<input type="text" id="acct-pwd-totp" name="totp_code" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code" required/>
</label>` : ''}
<p id="account-pwd-error" class="login-error" hidden></p>
<p id="account-pwd-ok" class="login-notice" hidden></p>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:0.5rem">Salvar nova senha</button>
</form>
</div>`;
restoreAccountPwdForm(saved);
bindAccountPwdForm(totpOn);
state.accountLoaded = true;
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
state.accountLoaded = false;
}
}
function readAccountPwdForm() {
const form = document.getElementById('account-pwd-form');
if (!form) return null;
const get = (id) => document.getElementById(id)?.value ?? '';
const hasValue = ['acct-pwd-current', 'acct-pwd-new', 'acct-pwd-new2', 'acct-pwd-totp']
.some((id) => get(id));
if (!hasValue) return null;
return {
current: get('acct-pwd-current'),
neu: get('acct-pwd-new'),
neu2: get('acct-pwd-new2'),
totp: get('acct-pwd-totp'),
};
}
function restoreAccountPwdForm(saved) {
if (!saved) return;
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val) el.value = val;
};
set('acct-pwd-current', saved.current);
set('acct-pwd-new', saved.neu);
set('acct-pwd-new2', saved.neu2);
set('acct-pwd-totp', saved.totp);
}
function bindAccountPwdForm(totpOn) {
const form = document.getElementById('account-pwd-form');
const errEl = document.getElementById('account-pwd-error');
const okEl = document.getElementById('account-pwd-ok');
if (!form || form.dataset.bound === '1') return;
form.dataset.bound = '1';
form.addEventListener('submit', async (e) => {
e.preventDefault();
errEl.hidden = true;
okEl.hidden = true;
const cur = document.getElementById('acct-pwd-current')?.value ?? '';
const neu = document.getElementById('acct-pwd-new')?.value ?? '';
const neu2 = document.getElementById('acct-pwd-new2')?.value ?? '';
if (neu !== neu2) {
errEl.textContent = 'As senhas não coincidem';
errEl.hidden = false;
return;
}
const payload = { current_password: cur, new_password: neu };
if (totpOn) {
payload.totp_code = (document.getElementById('acct-pwd-totp')?.value ?? '').trim();
}
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
await api('/v1/auth/change-password', {
method: 'POST',
body: JSON.stringify(payload),
});
okEl.textContent = 'Senha alterada com sucesso.';
okEl.hidden = false;
form.reset();
} catch (ex) {
errEl.textContent = ex.message;
errEl.hidden = false;
} finally {
btn.disabled = false;
}
});
}
const SOC_EVENT_LABELS = {
'session.started': 'Sessão iniciada',
'domain.validated': 'Domínio validado',
'dns.applied': 'DNS aplicado',
'onboarding.started': 'Onboarding iniciado',
'account.created': 'Conta criada',
'infra.synced': 'Infra sincronizada',
'onboarding.completed': 'Onboarding concluído',
'onboarding.failed': 'Onboarding falhou',
'integration.test': 'Teste integração',
...SECURITY_EVENT_LABELS,
};
function socWindowHours() {
return { '24h': 24, '48h': 48, '7d': 168 }[state.socWindow] || 24;
}
function socEventSeverity(eventType) {
if (eventType?.startsWith('security.')) {
if (eventType.includes('blocked') || eventType.includes('rejected') || eventType.includes('anomaly')) return 'high';
if (eventType.includes('csp') || eventType.includes('rate')) return 'warn';
return 'info';
}
if (eventType === 'onboarding.failed') return 'high';
if (eventType === 'onboarding.started' || eventType === 'session.started') return 'warn';
if (eventType === 'onboarding.completed' || eventType === 'account.created') return 'ok';
return 'info';
}
function socAreaChartSvg(values, width = 320, height = 88) {
const data = values?.length ? values : [0, 0, 0, 0, 0, 0];
const max = Math.max(...data, 1);
const padX = 4;
const padY = 6;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const pts = data.map((v, i) => {
const x = padX + (i / Math.max(data.length - 1, 1)) * innerW;
const y = padY + innerH - (v / max) * innerH;
return [x, y];
});
const line = pts.map((p) => p.join(',')).join(' ');
const area = `${padX},${padY + innerH} ${line} ${padX + innerW},${padY + innerH}`;
return `
<svg class="soc-area-chart" viewBox="0 0 ${width} ${height}" width="100%" height="${height}" aria-hidden="true">
<defs>
<linearGradient id="soc-area-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon points="${area}" fill="url(#soc-area-grad)"/>
<polyline fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${line}"/>
</svg>`;
}
function socPipelineHtml(stages, total) {
const order = ['started', 'domain_validated', 'dns_applied', 'account_created', 'infra_synced', 'completed'];
const max = Math.max(total || 1, ...order.map((k) => stages[k] || 0));
return order.map((key) => {
const n = stages[key] || 0;
const pct = max ? Math.round((n / max) * 100) : 0;
return `
<div class="soc-pipe-row">
<span class="soc-pipe-label">${esc(FUNNEL_LABELS[key] || key)}</span>
<div class="soc-pipe-bar"><div class="soc-pipe-fill" style="width:${pct}%"></div></div>
<span class="soc-pipe-count">${n}</span>
</div>`;
}).join('');
}
function socStatusKpiClass(status) {
if (status === 'ok') return 'ok';
if (status === 'critical') return 'critical';
return 'warn';
}
function socSessionRingStage(stage) {
if (stage === 'completed' || stage === 'failed') return stage;
return 'active';
}
function closeSocTestModal() {
const modal = document.getElementById('soc-test-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function bindSocTestModal() {
document.querySelectorAll('[data-close-soc-test-modal]').forEach((el) => {
el.addEventListener('click', closeSocTestModal);
});
}
function showSocWebhookTestResult(result) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const ok = result.accepted && result.status === 'accepted';
const dup = result.duplicate === true;
title.textContent = ok ? (dup ? 'Webhook OK (duplicado)' : 'Webhook OK') : 'Webhook com problema';
sub.textContent = fmtDate(result.tested_at || new Date().toISOString());
body.innerHTML = `
<div class="soc-test-result">
<div class="soc-test-status soc-test-status--${ok ? 'ok' : 'fail'}">
<span class="soc-sev soc-sev--${ok ? 'ok' : 'high'}"></span>
${esc(result.message || (ok ? 'Integração VM112 → VM122 respondendo corretamente.' : 'Falha ao processar webhook.'))}
</div>
<dl class="soc-test-kv">
<dt>Status</dt><dd>${esc(result.status || '—')}</dd>
<dt>Evento</dt><dd>${esc(result.event || '—')}</dd>
<dt>Origem</dt><dd>${esc(result.source || '—')}</dd>
<dt>Domínio</dt><dd>${esc(result.domain || '—')}</dd>
<dt>Sessão</dt><dd>${esc(result.session_id || '—')}</dd>
<dt>Duplicado</dt><dd>${dup ? 'sim' : 'não'}</dd>
<dt>Ticket criado</dt><dd>${result.ticket_created ? `sim (#${result.ticket_id})` : 'não'}</dd>
<dt>Disparado por</dt><dd>${esc(result.triggered_by || '—')}</dd>
</dl>
<p class="soc-test-hint">
Este teste simula um evento <code>integration.test</code> no endpoint
<code>POST /api/v1/webhooks/onboard</code> — o mesmo caminho usado pela VM112.
Não cria ticket de onboarding; apenas valida que a API grava o evento e o SOC consegue lê-lo.
</p>
<div class="soc-test-actions">
<button type="button" class="soc-btn" data-soc-goto-events>Ver em Eventos</button>
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
</div>
</div>`;
body.querySelector('[data-soc-goto-events]')?.addEventListener('click', () => {
closeSocTestModal();
state.eventSourceFilter = 'vm112-onboard';
document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => {
b.classList.toggle('active', b.dataset.source === 'vm112-onboard');
});
setView('events');
});
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function showSocWebhookTestError(err) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const msg = err?.message || String(err);
const is403 = /403|insufficient permissions|permiss/i.test(msg);
title.textContent = 'Falha no teste';
sub.textContent = 'Não foi possível completar o teste';
body.innerHTML = `
<div class="soc-test-result">
<div class="soc-test-status soc-test-status--fail">
<span class="soc-sev soc-sev--high"></span>
${esc(msg)}
</div>
${is403 ? `<p class="soc-test-hint">Apenas perfis <strong>super_admin</strong> e <strong>admin</strong> podem executar o teste de webhook.</p>` : ''}
<p class="soc-test-hint">Verifique se a API está online, se a sessão não expirou e se o usuário tem permissão.</p>
<div class="soc-test-actions">
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
</div>
</div>`;
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function showOpenPanelTestResult(result) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const ok = result.ok === true;
title.textContent = ok ? 'OpenPanel API — confirmado' : 'OpenPanel API — falha';
sub.textContent = `Spec 028 · ${result.steps_passed || 0}/${result.steps_total || 0} passos · ${result.duration_sec || '—'}s`;
const steps = (result.steps || []).map((s) => `
<li class="badge ${s.ok ? 'ok' : 'escalated'}">
<strong>${esc(s.name)}</strong> — ${esc(s.detail || (s.ok ? 'OK' : 'FAIL'))}
</li>`).join('');
body.innerHTML = `
<div class="soc-test-result">
<div class="soc-test-status ${ok ? 'soc-test-status--ok' : 'soc-test-status--fail'}">
<span class="soc-sev ${ok ? 'soc-sev--low' : 'soc-sev--high'}"></span>
${esc(result.message || (ok ? 'Multidomínio OK' : 'Falha'))}
</div>
<ul class="soc-alerts" style="list-style:none;padding:0;margin:0.75rem 0;display:flex;flex-direction:column;gap:0.35rem">${steps || '<li>—</li>'}</ul>
<p class="soc-test-hint">
Suite <code>openpanel-multidomain-api-confirm</code> — provisiona 2 contas temporárias
(2 domínios na plataforma), valida listagem e remove. Pode executar quantas vezes quiser.
Script CLI: <code>scripts/test-openpanel-multidomain-api.sh</code>
</p>
<div class="soc-test-actions">
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
</div>
</div>`;
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function showOpenPanelTestError(err) {
const modal = document.getElementById('soc-test-modal');
const title = document.getElementById('soc-test-modal-title');
const sub = document.getElementById('soc-test-modal-sub');
const body = document.getElementById('soc-test-modal-body');
if (!modal || !body) return;
const msg = err?.message || String(err);
const is403 = /403|permiss/i.test(msg);
title.textContent = 'OpenPanel API — erro';
sub.textContent = 'Teste não concluído';
body.innerHTML = `
<div class="soc-test-result">
<div class="soc-test-status soc-test-status--fail">
<span class="soc-sev soc-sev--high"></span>
${esc(msg)}
</div>
${is403 ? '<p class="soc-test-hint">Perfis: <strong>super_admin</strong>, <strong>devops</strong>, <strong>developer</strong>.</p>' : ''}
<div class="soc-test-actions">
<button type="button" class="soc-btn soc-btn--ghost" data-close-soc-test-modal>Fechar</button>
</div>
</div>`;
body.querySelector('[data-close-soc-test-modal]')?.addEventListener('click', closeSocTestModal);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
async function runOpenPanelApiTest() {
const btn = document.getElementById('btn-test-openpanel-api');
const prevLabel = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = 'Testando…';
}
try {
const r = await api('/v1/vm123/openpanel/test-confirm', { method: 'POST' });
showOpenPanelTestResult(r);
} catch (ex) {
showOpenPanelTestError(ex);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel || 'Testar multidomínio';
}
}
}
async function runWebhookIntegrationTest(refreshView) {
const btn = document.getElementById('soc-btn-test') || document.getElementById('btn-test-webhook');
const prevLabel = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = 'Testando…';
}
try {
const r = await api('/v1/integrations/onboard/test', { method: 'POST' });
showSocWebhookTestResult(r);
if (refreshView === 'infra2') await renderInfra2();
else if (refreshView === 'infra') await renderInfra();
} catch (ex) {
showSocWebhookTestError(ex);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel || 'Testar webhook';
}
}
}
async function renderInfra2() {
const el = document.getElementById('infra2-content');
if (!el) return;
if (window.DeskModules?.loaded && !DeskModules.isEnabled('infra2-soc')) {
el.innerHTML = '<p class="loading">Módulo Infra 2 SOC desativado. Active em <strong>Módulos</strong>.</p>';
return;
}
el.innerHTML = '<p class="loading">Carregando SOC…</p>';
const windowHours = socWindowHours();
try {
const [health, vm112, wazuh, funnel, eventsRes, secRes, summary] = await Promise.all([
api('/v1/integrations/health').catch(() => ({ status: 'unknown', alerts: [], vm112_onboard: {} })),
api('/v1/infra/vm112/status').catch(() => ({ error: 'indisponível' })),
api('/v1/infra/wazuh/status').catch(() => ({ error: 'indisponível' })),
api(`/v1/onboard/funnel?window_hours=${windowHours}`).catch(() => ({ stages: {}, active_sessions: [], sessions_total: 0 })),
api('/v1/webhooks/events?source=vm112-onboard').catch(() => ({ events: [] })),
window.DeskModules?.isEnabled('wizard-security')
? api('/v1/security/summary?window_hours=24').catch(() => ({}))
: Promise.resolve({}),
api('/v1/desk/summary').catch(() => ({ tickets_open: 0, recent_tickets: [] })),
]);
const onboard = health.vm112_onboard || {};
const lastWh = onboard.last_webhook || {};
const gapMin = onboard.gap_minutes != null ? Math.round(onboard.gap_minutes) : null;
const alerts = health.alerts || [];
const vmOk = vm112.vm112?.status === 'ok';
const wazuhOk = wazuh.api_online === true || wazuh.http_status === 401 || wazuh.http_status === 200;
const intStatus = health.status || 'unknown';
const liveCls = intStatus === 'ok' ? '' : intStatus === 'critical' ? 'critical' : 'warn';
const secSummary = secRes || {};
const secRecent = (secSummary.recent || []).map((ev) => ({
id: `sec-${ev.id}`,
event_type: ev.event_type,
created_at: ev.created_at,
payload: { domain: ev.domain, session_id: ev.session_id },
domain: ev.domain,
_security: true,
}));
const allEvents = (eventsRes.events || []).map((ev) => ({
...ev,
payload: typeof ev.payload === 'object' ? ev.payload : {},
}));
const windowEvents = allEvents.filter((ev) => isInWindow(ev.created_at, windowHours));
const chartBuckets = bucketEvents(windowEvents, windowHours, 24);
const eventsPerHour = windowHours ? Math.round((windowEvents.length / windowHours) * 10) / 10 : 0;
const feedEvents = [...allEvents, ...secRecent]
.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
.slice(0, 18);
const sessions = (funnel.active_sessions || [])
.filter((s) => s.domain || s.session_id)
.sort((a, b) => new Date(b.last_event_at || 0) - new Date(a.last_event_at || 0));
const sessionTimings = {};
if (window.DeskModules?.isEnabled('funnel-timing')) {
const tops = sessions.slice(0, 8).filter((s) => s.session_id);
const timingResults = await Promise.all(
tops.map((s) => api(`/v1/onboard/sessions/${encodeURIComponent(s.session_id)}/timeline`).catch(() => null))
);
tops.forEach((s, i) => {
if (timingResults[i]?.timing) sessionTimings[s.session_id] = timingResults[i].timing;
});
}
const newestId = feedEvents[0]?.id;
const flashNew = state.socLastEventId && newestId && newestId > state.socLastEventId;
state.socLastEventId = newestId || state.socLastEventId;
const onboardTicketsOpen = (summary.recent_tickets || []).filter(
(t) => (t.source === 'vm112-onboard' || String(t.subject || '').includes('[onboarding]')) && t.status !== 'closed'
).length;
const nowLabel = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
el.innerHTML = `
<div class="soc-console" id="soc-console-root">
<header class="soc-header">
<div class="soc-header-left">
<span class="soc-live-dot ${liveCls}" aria-hidden="true"></span>
<h3>SOC Operations Center</h3>
<span class="soc-meta">VM112 → VM122 · atualizado ${esc(nowLabel)} · refresh 15s</span>
</div>
<div class="soc-header-actions">
<select class="soc-select" id="soc-window-select" aria-label="Janela de tempo">
<option value="24h" ${state.socWindow === '24h' ? 'selected' : ''}>24 horas</option>
<option value="48h" ${state.socWindow === '48h' ? 'selected' : ''}>48 horas</option>
<option value="7d" ${state.socWindow === '7d' ? 'selected' : ''}>7 dias</option>
</select>
<button type="button" class="soc-btn" id="soc-btn-test">Testar webhook</button>
<button type="button" class="soc-btn soc-btn--ghost" id="soc-btn-refresh">Atualizar</button>
</div>
</header>
<div class="soc-kpi-grid">
<div class="soc-kpi soc-kpi--${socStatusKpiClass(intStatus)}">
<span class="soc-kpi-label">Integração</span>
<span class="soc-kpi-value">${esc(intStatus)}</span>
<span class="soc-kpi-sub">VM112 onboard</span>
</div>
<div class="soc-kpi soc-kpi--${gapMin != null && gapMin > (health.webhook_gap_alert_minutes || 15) ? 'critical' : 'ok'}">
<span class="soc-kpi-label">Gap webhook</span>
<span class="soc-kpi-value">${gapMin != null ? `${gapMin}m` : '—'}</span>
<span class="soc-kpi-sub">limite ${health.webhook_gap_alert_minutes || 15} min</span>
</div>
<div class="soc-kpi soc-kpi--info">
<span class="soc-kpi-label">Eventos</span>
<span class="soc-kpi-value">${windowEvents.length}</span>
<span class="soc-kpi-sub">~${eventsPerHour}/h · ${state.socWindow}</span>
</div>
<div class="soc-kpi soc-kpi--info">
<span class="soc-kpi-label">Sessões</span>
<span class="soc-kpi-value">${funnel.sessions_total || sessions.length}</span>
<span class="soc-kpi-sub">funil ativo</span>
</div>
<div class="soc-kpi soc-kpi--${onboardTicketsOpen > 0 ? 'warn' : 'ok'}">
<span class="soc-kpi-label">Tickets onboard</span>
<span class="soc-kpi-value">${onboardTicketsOpen}</span>
<span class="soc-kpi-sub">abertos agora</span>
</div>
<div class="soc-kpi soc-kpi--${alerts.length ? 'warn' : 'ok'}">
<span class="soc-kpi-label">Alertas</span>
<span class="soc-kpi-value">${alerts.length}</span>
<span class="soc-kpi-sub">${lastWh.event ? esc(lastWh.event) : 'sem eventos'}</span>
</div>
${window.DeskModules?.isEnabled('wizard-security') ? `
<div class="soc-kpi soc-kpi--${(secSummary.inputs_blocked || 0) + (secSummary.handoffs_rejected || 0) > 0 ? 'critical' : (secSummary.total || 0) > 0 ? 'warn' : 'ok'}">
<span class="soc-kpi-label">Segurança wizard</span>
<span class="soc-kpi-value">${secSummary.total || 0}</span>
<span class="soc-kpi-sub">CSP ${secSummary.csp_violations || 0} · bloq ${secSummary.inputs_blocked || 0}</span>
</div>` : ''}
</div>
<div class="soc-topology" aria-label="Topologia de integração">
<div class="soc-node">
<span class="soc-node-dot ${vmOk ? 'ok' : 'bad'}"></span>
VM112 Wizard
</div>
<span class="soc-flow"><strong>webhook</strong> POST /onboard →</span>
<div class="soc-node">
<span class="soc-node-dot ok"></span>
VM122 Desk
</div>
<span class="soc-flow">←</span>
<div class="soc-node">
<span class="soc-node-dot ${wazuhOk ? 'ok' : 'bad'}"></span>
VM104 Wazuh
</div>
<span class="soc-flow"><strong>alertas</strong> level ≥10</span>
</div>
<div class="soc-main-grid">
<div class="soc-panel">
<div class="soc-panel-head">
<h4>Feed ao vivo — VM112 + Segurança</h4>
<span class="soc-meta">${feedEvents.length} recentes</span>
</div>
<div class="soc-panel-body">
${feedEvents.length ? `
<table class="soc-feed">
<thead><tr><th></th><th>Evento</th><th>Domínio</th><th>Hora</th></tr></thead>
<tbody>
${feedEvents.map((ev, i) => {
const p = ev.payload || {};
const sev = socEventSeverity(ev.event_type);
const isNew = flashNew && i === 0;
return `
<tr class="soc-feed-row${isNew ? ' soc-feed-row--new' : ''}${ev._security ? ' soc-feed-row--security' : ''}">
<td><span class="soc-sev soc-sev--${sev}" title="${sev}"></span></td>
<td class="soc-event-name">${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)}</td>
<td class="soc-event-domain">${esc(p.domain || ev.domain || '—')}</td>
<td class="soc-event-time">${relativeTimeAgo(ev.created_at)}</td>
</tr>`;
}).join('')}
</tbody>
</table>` : '<p class="soc-empty">Nenhum evento VM112 registrado</p>'}
</div>
</div>
<div class="soc-panel">
<div class="soc-panel-head">
<h4>Volume &amp; funil</h4>
<span class="soc-meta">${state.socWindow}</span>
</div>
<div class="soc-panel-body">
<div class="soc-chart-wrap">
<div class="soc-chart-legend">
<span>Eventos VM112</span>
<span>máx ${Math.max(...chartBuckets, 0)}</span>
</div>
${socAreaChartSvg(chartBuckets)}
</div>
<div class="soc-pipeline">
${socPipelineHtml(funnel.stages || {}, funnel.sessions_total || 0)}
</div>
</div>
</div>
<div class="soc-panel">
<div class="soc-panel-head">
<h4>Sessões VM112</h4>
<span class="soc-meta">${sessions.length} ativas</span>
</div>
<div class="soc-panel-body">
<div class="soc-session-list">
${sessions.length ? sessions.slice(0, 10).map((s) => {
const stage = s.current_stage || 'started';
const ringCls = socSessionRingStage(stage);
const initials = (s.domain || '??').slice(0, 2).toUpperCase();
const tmeta = sessionTimings[s.session_id];
const timingBadge = tmeta
? `<span class="timing-badge soc-timing-badge" title="Duração total">Σ ${esc(tmeta.total_duration_label)}</span>`
: '';
const idleHint = tmeta && !tmeta.is_completed
? ` · parado ${esc(tmeta.idle_since_label)}`
: '';
return `
<button type="button" class="soc-session-card${s.stale ? ' stale' : ''}" data-soc-session="${esc(s.session_id || '')}" data-soc-ticket="${s.ticket_id || ''}">
<span class="soc-session-ring ${ringCls}">${esc(initials)}</span>
<span class="soc-session-main">
<strong>${esc(s.domain || 'sem domínio')}</strong>
<span>${esc(FUNNEL_LABELS[stage] || stage)} · onboarding · ${relativeTimeAgo(s.last_event_at)}${idleHint}</span>
${s.session_id ? `<span class="soc-session-hash">${sessionHashHtml(s.session_id)}</span>` : ''}
</span>
${timingBadge}
${s.ticket_id ? `<span class="badge ok">#${s.ticket_id}</span>` : '<span class="badge review">—</span>'}
</button>`;
}).join('') : '<p class="soc-empty">Sem sessões no período</p>'}
</div>
</div>
</div>
</div>
<div class="soc-bottom-grid">
<div class="soc-panel" style="min-height:160px">
<div class="soc-panel-head"><h4>Alertas SOC</h4></div>
<div class="soc-panel-body">
<ul class="soc-alert-list">
${alerts.length ? alerts.map((a) => `
<li class="soc-alert-item soc-alert-item--${a.level === 'critical' ? 'critical' : 'warn'}">
<span class="soc-sev soc-sev--${a.level === 'critical' ? 'high' : 'warn'}"></span>
${esc(a.message)}
</li>`).join('') : `
<li class="soc-alert-item soc-alert-item--ok">
<span class="soc-sev soc-sev--ok"></span>
Integração saudável — sem alertas ativos
</li>`}
${lastWh.domain ? `
<li class="soc-alert-item">
<span class="soc-sev soc-sev--info"></span>
Último: <strong>${esc(lastWh.event)}</strong> · ${esc(lastWh.domain)} · ${relativeTimeAgo(lastWh.created_at)}
</li>` : ''}
</ul>
</div>
</div>
<div class="soc-panel" style="min-height:160px">
<div class="soc-panel-head"><h4>Health dos nós</h4></div>
<div class="soc-panel-body">
<div class="soc-health-grid">
<div class="soc-health-card">
<h5>VM112 Portal</h5>
<dl>
<dt>HTTP</dt><dd>${vm112.http_status ?? ''}</dd>
<dt>Service</dt><dd>${esc(vm112.vm112?.service || vm112.error || '')}</dd>
<dt>API</dt><dd>${onboard.vm112_api?.reachable ? 'OK' : 'offline'}</dd>
</dl>
</div>
<div class="soc-health-card">
<h5>VM122 Desk</h5>
<dl>
<dt>Integração</dt><dd>${esc(intStatus)}</dd>
<dt>Gap</dt><dd>${gapMin != null ? `${gapMin} min` : ''}</dd>
<dt>Webhook</dt><dd>${esc(lastWh.event || '')}</dd>
</dl>
</div>
<div class="soc-health-card">
<h5>VM104 Wazuh</h5>
<dl>
<dt>API</dt><dd>${wazuh.http_status ?? ''}</dd>
<dt>Regra</dt><dd>level 10</dd>
<dt>Status</dt><dd>${wazuhOk ? 'online' : 'check'}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>`;
document.getElementById('soc-window-select')?.addEventListener('change', (e) => {
state.socWindow = e.target.value;
renderInfra2();
});
document.getElementById('soc-btn-refresh')?.addEventListener('click', () => renderInfra2());
document.getElementById('soc-btn-test')?.addEventListener('click', () => runWebhookIntegrationTest('infra2'));
el.querySelectorAll('[data-soc-session]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedSessionId = btn.dataset.socSession;
const tid = btn.dataset.socTicket;
state.selectedTicketId = tid ? Number(tid) : null;
setView('tickets');
});
});
} catch (e) {
el.innerHTML = `<p class="loading">Erro SOC: ${esc(e.message)}</p>`;
}
}
function infraKvHtml(items) {
return `<dl class="infra-kv">${items.map(([label, value]) =>
`<div><dt>${esc(label)}</dt><dd>${value}</dd></div>`
).join('')}</dl>`;
}
async function renderInfra() {
const el = document.getElementById('infra-content');
el.innerHTML = '<p class="loading">Verificando…</p>';
try {
const [vm112, wazuh, integrations, health] = await Promise.all([
api('/v1/infra/vm112/status'),
api('/v1/infra/wazuh/status'),
api('/v1/integrations'),
api('/v1/integrations/health'),
]);
const onboard = health.vm112_onboard || {};
const last = onboard.last_webhook;
const gap = onboard.gap_minutes != null ? `${Math.round(onboard.gap_minutes)} min` : '—';
const vmOk = onboard.vm112_api?.reachable;
const wazuhOk = wazuh.http_status === 200;
const statusCls = health.status === 'ok' ? 'ok' : health.status === 'critical' ? 'escalated' : 'assisting';
const heroHealthDot = health.status === 'ok' ? '' : health.status === 'critical' ? 'infra-hero-dot--bad' : 'infra-hero-dot--warn';
const alerts = (health.alerts || []).map((a) =>
`<li class="badge ${a.level === 'critical' ? 'escalated' : 'assisting'}">${esc(a.message)}</li>`
).join('') || '<li class="muted">Nenhum alerta activo</li>';
el.innerHTML = `
<div class="infra-page">
<div class="infra-hero">
<div class="infra-hero-chip">
<span class="infra-hero-dot ${heroHealthDot}" aria-hidden="true"></span>
<div class="infra-hero-body">
<strong>SOC integração</strong>
<span>Webhook VM112 · gap ${gap}</span>
</div>
<span class="badge ${statusCls}">${esc(health.status || '—')}</span>
</div>
<div class="infra-hero-chip">
<span class="infra-hero-dot ${vmOk ? '' : 'infra-hero-dot--warn'}" aria-hidden="true"></span>
<div class="infra-hero-body">
<strong>VM112 Portal</strong>
<span>${esc(vm112.vm112?.service || vm112.error || '—')}</span>
</div>
<span class="badge ${vmOk ? 'ok' : 'review'}">${vmOk ? 'online' : 'check'}</span>
</div>
<div class="infra-hero-chip">
<span class="infra-hero-dot ${wazuhOk ? '' : 'infra-hero-dot--warn'}" aria-hidden="true"></span>
<div class="infra-hero-body">
<strong>VM104 Wazuh</strong>
<span>API HTTP ${wazuh.http_status ?? '—'}</span>
</div>
<span class="badge ${wazuhOk ? 'ok' : 'review'}">${wazuhOk ? 'online' : 'check'}</span>
</div>
</div>
<div class="infra-grid">
<article class="ws-panel infra-panel infra-panel--wide">
<div class="ws-panel-head ws-panel-head--teal">SOC — Integração VM112</div>
<div class="ws-panel-body">
${infraKvHtml([
['Último evento', last ? esc(last.event) : '—'],
['Domínio', last?.domain ? esc(last.domain) : '—'],
['Há quanto tempo', gap],
['VM112 API', vmOk ? 'OK' : esc(onboard.vm112_api?.error || 'offline')],
])}
<ul class="infra-alert-list">${alerts}</ul>
<div class="infra-actions">
<button type="button" class="btn secondary btn-sm" id="btn-test-webhook">Testar webhook</button>
<button type="button" class="btn secondary btn-sm" id="btn-refresh-health">Atualizar</button>
</div>
<p class="infra-hint">Alerta se gap &gt; ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
</div>
</article>
<article class="ws-panel infra-panel">
<div class="ws-panel-head ws-panel-head--rose">Códigos purge · Spec 032</div>
<div class="ws-panel-body" id="purge-auth-infra-panel"><p class="loading">A carregar…</p></div>
</article>
<article class="ws-panel infra-panel">
<div class="ws-panel-head ws-panel-head--orange">OpenPanel API</div>
<div class="ws-panel-body">
<p class="infra-hint">Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup.</p>
<div class="infra-actions">
<button type="button" class="btn secondary btn-sm" id="btn-test-openpanel-api">Testar multidomínio</button>
</div>
</div>
</article>
<article class="ws-panel infra-panel">
<div class="ws-panel-head ws-panel-head--teal">VM112 — Onboard</div>
<div class="ws-panel-body">
${infraKvHtml([
['HTTP', String(vm112.http_status ?? '—')],
['Service', esc(vm112.vm112?.service || vm112.error || '—')],
])}
</div>
</article>
<article class="ws-panel infra-panel">
<div class="ws-panel-head ws-panel-head--slate">VM104 — Wazuh SOC</div>
<div class="ws-panel-body">
${infraKvHtml([
['API HTTP', String(wazuh.http_status ?? '—')],
['Integração', 'webhook level ≥ 10 → VM122'],
])}
</div>
</article>
<article class="ws-panel infra-panel infra-panel--wide">
<div class="ws-panel-head ws-panel-head--violet">Integrações activas</div>
<div class="ws-panel-body infra-json-panel" style="padding:0">
<pre class="raw">${esc(JSON.stringify(integrations, null, 2))}</pre>
</div>
</article>
</div>
</div>`;
document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra());
document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest());
await renderPurgeAuthInfraPanel();
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderPurgeAuthInfraPanel() {
const panel = document.getElementById('purge-auth-infra-panel');
if (!panel) return;
try {
const meta = await api('/v1/infra/purge-auth-domains');
const domainChips = (meta.domains || []).map((d) =>
`<span class="infra-domain-chip">${esc(d)}</span>`
).join('') || '<span class="ticket-meta">Nenhum</span>';
const canGen = meta.can_generate && typeof canManageUsers === 'function' && canManageUsers();
let codesHtml = '';
if (canGen) {
const data = await api('/v1/infra/purge-auth-codes?limit=20');
const rows = (data.codes || []).map((c) => `
<tr>
<td><code>${esc(c.domain)}</code></td>
<td>${esc(c.note || '—')}</td>
<td>${fmtDate(c.expires_at)}</td>
<td>${esc(c.created_by || '—')}</td>
</tr>`).join('');
codesHtml = `
<p class="infra-hint">Gere código com senha Root — use na conferência antes do purge em Serviços.</p>
<div class="infra-domain-chips">${domainChips}</div>
<form id="purge-auth-generate-form" class="purge-auth-form">
<div>
<label for="purge-auth-domain">Domínio</label>
<input type="text" id="purge-auth-domain" placeholder="myvexx.com" required />
</div>
<div>
<label for="purge-auth-ttl">Validade (horas)</label>
<input type="number" id="purge-auth-ttl" value="24" min="1" max="168" />
</div>
<div style="grid-column:1/-1">
<label for="purge-auth-note">Nota (conferência / ticket)</label>
<input type="text" id="purge-auth-note" placeholder="Autorizado em call com Roger" />
</div>
<div style="grid-column:1/-1">
<label for="purge-auth-root-pwd">Senha Root</label>
<input type="password" id="purge-auth-root-pwd" autocomplete="current-password" required />
</div>
<button type="submit" class="btn secondary btn-sm">Gerar código</button>
</form>
<p id="purge-auth-gen-msg" class="ticket-meta"></p>
<div id="purge-auth-generated" class="purge-auth-generated hidden"></div>
<h4 style="margin:0.85rem 0 0.35rem;font-size:0.78rem;text-transform:uppercase;color:#64748b">Códigos activos</h4>
<div class="infra-table-wrap">
<table class="purge-history-table">
<thead><tr><th>Domínio</th><th>Nota</th><th>Expira</th><th>Por</th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">Nenhum código activo</td></tr>'}</tbody>
</table>
</div>`;
} else {
codesHtml = `
<div class="infra-domain-chips">${domainChips}</div>
<p class="infra-hint">Apenas <strong>super_admin (root)</strong> gera códigos. Peça o código ao root antes do purge em Serviços.</p>`;
}
panel.innerHTML = codesHtml;
const form = panel.querySelector('#purge-auth-generate-form');
if (form) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const msg = panel.querySelector('#purge-auth-gen-msg');
const out = panel.querySelector('#purge-auth-generated');
const domain = panel.querySelector('#purge-auth-domain')?.value?.trim() || '';
const note = panel.querySelector('#purge-auth-note')?.value?.trim() || '';
const ttl = parseInt(panel.querySelector('#purge-auth-ttl')?.value || '24', 10);
const rootPwd = panel.querySelector('#purge-auth-root-pwd')?.value || '';
if (!domain || !rootPwd) {
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
return;
}
if (msg) msg.textContent = 'A gerar…';
try {
const res = await api('/v1/infra/purge-auth-codes', {
method: 'POST',
body: JSON.stringify({
domain,
root_password: rootPwd,
note,
ttl_hours: ttl,
}),
});
if (msg) msg.textContent = 'Código gerado — copie agora (não será mostrado de novo).';
if (out) {
out.classList.remove('hidden');
out.innerHTML = `
<p><strong>Código:</strong> <code class="purge-auth-code-display">${esc(res.code)}</code></p>
<p class="ticket-meta">Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}</p>`;
}
panel.querySelector('#purge-auth-root-pwd').value = '';
} catch (e) {
if (msg) msg.textContent = e.message || 'Falha ao gerar código';
}
});
}
} catch (e) {
panel.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function refresh(options = {}) {
const { poll = false } = options;
await loadHealth();
if (poll && state.view === 'account') {
return;
}
if (state.view === 'dashboard') await renderDashboard();
if (state.view === 'email-migration' && window.DeskEmailMigration?.renderPage) await window.DeskEmailMigration.renderPage();
if (state.view === 'overview') await renderOverview();
if (state.view === 'overview-home') await renderOverviewHome({ poll });
if (state.view === 'leads') await renderLeads();
if (state.view === 'tickets') {
if (poll && window.TicketsWorkspace?._pageReady) await TicketsWorkspace.softRefresh();
else await renderTickets({ poll: false });
}
if (state.view === 'events') await renderEvents();
if (state.view === 'tenants') await renderTenants();
if (state.view === 'infra') await renderInfra();
if (state.view === 'infra2') await renderInfra2();
if (state.view === 'messages') await renderMessages();
if (state.view === 'admin') await renderAdmin();
if (state.view === 'modules') await renderModules();
if (state.view === 'account') await renderAccount();
}
document.querySelectorAll('.nav button').forEach((btn) => {
btn.addEventListener('click', () => setView(btn.dataset.view));
});
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
state.ticketFilter = btn.dataset.filter;
document.querySelectorAll('.filter-btn[data-filter]').forEach((b) => b.classList.toggle('active', b === btn));
renderTickets();
});
});
document.querySelectorAll('.filter-btn[data-source]').forEach((btn) => {
btn.addEventListener('click', () => {
const kind = btn.dataset.kind || 'ticket';
if (kind === 'event') {
state.eventSourceFilter = btn.dataset.source;
document.querySelectorAll('.filter-btn[data-kind="event"]').forEach((b) => b.classList.toggle('active', b === btn));
renderEvents();
} else {
state.sourceFilter = btn.dataset.source;
document.querySelectorAll('.filter-btn[data-kind="ticket"]').forEach((b) => b.classList.toggle('active', b === btn));
renderTickets();
}
});
});
document.querySelectorAll('[data-events-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.eventsTab = btn.dataset.eventsTab || 'webhooks';
document.querySelectorAll('[data-events-tab]').forEach((b) => b.classList.toggle('active', b === btn));
renderEvents();
});
});
document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
el.addEventListener('click', closePurgeHistoryModal);
});
document.getElementById('btn-refresh')?.addEventListener('click', () => {
if (state.view === 'account') {
state.accountLoaded = false;
}
refresh();
});
(async function boot() {
const dash = document.getElementById('dashboard-content');
try {
if (!getToken()) {
window.location.replace('/login.html');
return;
}
setupSidebarUser();
await DeskModules.load();
applyRoleNav();
DeskModules.applyVisibility();
bindOverviewModal();
bindTeamDrawerClose();
bindSocTestModal();
setView('dashboard');
ensureValidSession().then((valid) => {
if (!valid) window.location.replace('/login.html');
else setupSidebarUser();
});
reschedulePoll();
} catch (err) {
console.error('boot failed', err);
if (dash) {
dash.innerHTML = `<p class="loading">Erro ao iniciar (${esc(err.message)}). <a href="/login.html">Voltar ao login</a></p>`;
}
}
})();