diff --git a/ligbox-ops-platform/deploy/vm112-wizard/README.md b/ligbox-ops-platform/deploy/vm112-wizard/README.md
new file mode 100644
index 0000000..59178eb
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm112-wizard/README.md
@@ -0,0 +1,31 @@
+# Deploy mirror — Wizard VM112 (2026-06-19)
+
+Espelho dos ficheiros alterados em produção na **VM112** (`/opt/ligbox-wizard`).
+
+## Alterações desta entrega
+
+| Ficheiro | Alteração |
+|----------|-----------|
+| `frontend/src/main-wizard.jsx` | **Fix P0:** rota `/admin` → `DomainAdmin` (build wizard-only) |
+| `frontend/src/clientSettings.js` | Normaliza `smtp_note` legado (remove IP partilhado / Ibytera em cache) |
+| `frontend/src/FinishToolbar.jsx` | CTAs passo Concluído: webmail, portal, finalizar |
+| `backend/routers/onboarding.py` | `smtp_note` canónico; strings Ibytera → Ligbox |
+
+## Aplicar na VM112
+
+```bash
+cd /opt/ligbox-wizard
+cp deploy/vm112-wizard/frontend/src/main-wizard.jsx frontend/src/
+cp deploy/vm112-wizard/frontend/src/clientSettings.js frontend/src/
+# ... demais ficheiros conforme diff
+cd frontend && npm run build:wizard
+systemctl restart ligbox-wizard
+```
+
+## Spec / KB
+
+- Spec 025 — secção «Passo Concluído — CTAs»
+- KB: `docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md`
+- Funcional: `docs/vms/vm112/SPEC_FUNCIONAL_VM112_LIGBOX_ONBOARD.md` §12.3
+
+**Roger · VM130 spec-hub · 2026-06-19**
diff --git a/ligbox-ops-platform/deploy/vm112-wizard/backend/routers/onboarding.py b/ligbox-ops-platform/deploy/vm112-wizard/backend/routers/onboarding.py
new file mode 100644
index 0000000..9f58b8a
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm112-wizard/backend/routers/onboarding.py
@@ -0,0 +1,777 @@
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
+from pydantic import BaseModel, EmailStr, Field
+
+from app.config import cloudflare_token_path, settings
+from app.deps import bind_onboarding_session, get_session_from_request
+from app.services import activity_log, carbonio, dns_verify, domain_registry, notifications, onboard_handoff
+from app.services import infrastructure, ops_webhook, support_tickets
+from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers
+from app.services.domain_format import cloudflare_error_http_detail, invalid_domain_http_detail, normalize_domain, validate_primary_domain
+from app.services.mail_aliases import sanitize_mail_aliases
+
+router = APIRouter(
+ prefix="/onboarding",
+ tags=["onboarding"],
+ dependencies=[Depends(bind_onboarding_session)],
+)
+
+
+def _cloudflare_token_missing_error() -> str:
+ return (
+ "Token Cloudflare não configurado. Na VM112: "
+ f"{cloudflare_token_path()} (ver secrets/README.txt)"
+ )
+
+
+class DomainRequest(BaseModel):
+ domain: str = Field(..., min_length=3, max_length=253)
+
+
+class ValidateDomainRequest(BaseModel):
+ domain: str = Field(..., min_length=3, max_length=253)
+ mail_aliases: list[str] = Field(default_factory=list, max_length=5)
+
+
+class CreateAccountRequest(BaseModel):
+ domain: str
+ local_part: str = "admin"
+ password: str | None = Field(None, min_length=8)
+ use_server_password: bool = False
+ display_name: str | None = None
+ notify_email: EmailStr | None = None
+ send_welcome: bool = True
+ dns_mode: str | None = None
+ mail_aliases: list[str] = Field(default_factory=list, max_length=5)
+
+
+class CloudflareApplyRequest(BaseModel):
+ domain: str
+ zone_id: str | None = None
+ use_cloudflare: bool = True
+ mail_aliases: list[str] = Field(default_factory=list, max_length=5)
+
+
+class SupportTicketRequest(BaseModel):
+ domain: str = Field(..., min_length=3, max_length=253)
+ account_email: str | None = None
+ client_note: str | None = Field(default=None, max_length=2000)
+
+
+def _client_settings(domain: str, email: str) -> dict:
+ mail = f"mail.{domain}"
+ return {
+ "imap": {"host": mail, "port": 993, "security": "SSL/TLS"},
+ "smtp": {"host": mail, "port": 465, "security": "SSL/TLS"},
+ "pop3": {"host": mail, "port": 995, "security": "SSL/TLS"},
+ "webmail": f"https://{mail}/",
+ "email": email,
+ "smtp_note": "Use a porta 465 com SSL/TLS para envio. Não use a porta 587.",
+ }
+
+
+@router.get("/health")
+def health():
+ return {"status": "ok", "service": "ibytera-mail-portal"}
+
+
+@router.post("/session/reset")
+def reset_session(
+ request: Request,
+ session_id: str = Depends(bind_onboarding_session),
+ domain: str | None = None,
+):
+ """Limpa logs da sessão actual e, opcionalmente, histórico unificado do domínio."""
+ get_session_from_request(request)
+ cleared_sessions: list[str] = []
+ if session_id:
+ activity_log.clear(session_id)
+ cleared_sessions.append(session_id)
+ domain_q = (domain or "").strip().lower()
+ if domain_q and len(domain_q) >= 3:
+ cleared_sessions.extend(activity_log.clear_for_domain(domain_q))
+ cleared_sessions = sorted(set(cleared_sessions))
+ activity_log.info("Sessão reiniciada — cache de logs limpo", source="portal")
+ return {
+ "ok": True,
+ "cleared_sessions": cleared_sessions,
+ "message": "Cache da sessão limpo. Recarregue a página.",
+ }
+
+
+@router.post("/support/ticket")
+def open_support_ticket(
+ body: SupportTicketRequest,
+ background_tasks: BackgroundTasks,
+ session_id: str = Depends(bind_onboarding_session),
+):
+ """Abre chamado de suporte (webmail/infra pendente) com link da sessão completa."""
+ domain = body.domain.lower().strip()
+ aliases = domain_registry.get_domain_record(domain)
+ mail_aliases = (aliases or {}).get("mail_aliases") or []
+ infra_status = infrastructure.get_status(domain, mail_aliases)
+
+ layman = support_tickets.build_layman_explanation(domain, body.account_email, infra_status)
+ ticket, created = support_tickets.find_or_create_ticket(
+ domain=domain,
+ session_id=session_id or "",
+ account_email=body.account_email,
+ infra_status=infra_status,
+ client_note=body.client_note,
+ )
+
+ pending = [s.get("label", s.get("id")) for s in infra_status.get("steps", []) if not s.get("ok")]
+ try:
+ notifications.notify_support_ticket_opened(
+ ticket_id=ticket["ticket_id"],
+ domain=domain,
+ account_email=body.account_email,
+ support_url=ticket["support_url"],
+ pending_summary="; ".join(pending) or "A verificar",
+ session_id=session_id or "",
+ client_note=body.client_note,
+ )
+ except Exception as exc:
+ activity_log.warn(f"Notificação de chamado falhou (ticket criado): {exc}", source="portal")
+
+ if created:
+ background_tasks.add_task(
+ ops_webhook.emit_event,
+ "onboarding.escalated",
+ domain=domain,
+ session_id=session_id or "",
+ data={
+ "reason": "client_support_request",
+ "wizard_ticket_id": ticket["ticket_id"],
+ "client_note": body.client_note,
+ "source": "wizard_support_modal",
+ },
+ )
+
+ if created:
+ activity_log.info(
+ f"Cliente abriu chamado {ticket['ticket_id']} — suporte notificado",
+ source="portal",
+ )
+ else:
+ activity_log.info(
+ f"Cliente reutilizou chamado {ticket['ticket_id']} — sem duplicar",
+ source="portal",
+ )
+
+ msg = (
+ "Chamado registrado. Nossa equipe (assistente + suporte humano) recebeu o histórico desta sessão."
+ if created
+ else f"Já existe um chamado aberto ({ticket['ticket_id']}). Acompanhe o andamento abaixo."
+ )
+
+ return {
+ "ticket_id": ticket["ticket_id"],
+ "support_url": ticket["support_url"],
+ "tracking_url": ticket.get("tracking_url") or ticket["support_url"],
+ "created": created,
+ "layman": layman,
+ "message": msg,
+ }
+
+
+@router.get("/support/ticket/by-session")
+def get_open_ticket_for_session(session_id: str = Depends(bind_onboarding_session)):
+ """Chamado OB- aberto para banner no wizard."""
+ ticket = support_tickets.find_open_ticket_for_session(session_id or "")
+ if not ticket:
+ return {"ticket": None}
+ return {
+ "ticket": {
+ "ticket_id": ticket.get("ticket_id"),
+ "tracking_url": ticket.get("tracking_url") or ticket.get("support_url"),
+ "status": ticket.get("status"),
+ }
+ }
+
+
+@router.get("/support/ticket/{ticket_id}")
+def get_support_ticket(ticket_id: str):
+ ticket = support_tickets.get_ticket(ticket_id)
+ if not ticket:
+ raise HTTPException(404, "Chamado não encontrado.")
+ return ticket
+
+
+class SupportTicketNoteRequest(BaseModel):
+ session_id: str = Field(..., min_length=8)
+ message: str = Field(..., min_length=1, max_length=2000)
+
+
+@router.get("/support/ticket/{ticket_id}/public")
+def get_support_ticket_public(
+ ticket_id: str,
+ session_id: str = Query(..., min_length=8),
+):
+ """Portal cliente Spec 026 — requer par ticket + session."""
+ from app.services import assist_session, infrastructure
+ from app.services.domain_registry import get_domain_record
+
+ ticket = support_tickets.get_ticket(ticket_id.strip().upper())
+ if not ticket:
+ raise HTTPException(404, detail={"message": "Chamado não encontrado."})
+ if (ticket.get("session_id") or "").strip() != session_id.strip():
+ raise HTTPException(403, detail={"message": "Sessão não corresponde a este chamado."})
+
+ domain = ticket.get("domain") or ""
+ rec = get_domain_record(domain) if domain else None
+ aliases = (rec or {}).get("mail_aliases") or []
+ infra_status = infrastructure.get_status(domain, aliases) if domain else {}
+ ticket["infra_status"] = infra_status
+ assist_state = assist_session.public_state(session_id.strip())
+ return support_tickets.build_public_view(ticket, infra_status=infra_status, assist_state=assist_state)
+
+
+@router.post("/support/ticket/{ticket_id}/note")
+def add_support_ticket_note(
+ ticket_id: str,
+ body: SupportTicketNoteRequest,
+ session_id: str = Depends(bind_onboarding_session),
+):
+ sid = (body.session_id or session_id or "").strip()
+ updated = support_tickets.add_client_note(ticket_id.strip().upper(), sid, body.message)
+ if not updated:
+ raise HTTPException(404, detail={"message": "Chamado não encontrado ou sessão inválida."})
+ from app.services import assist_session, infrastructure
+ from app.services.domain_registry import get_domain_record
+
+ domain = updated.get("domain") or ""
+ rec = get_domain_record(domain) if domain else None
+ aliases = (rec or {}).get("mail_aliases") or []
+ infra_status = infrastructure.get_status(domain, aliases) if domain else {}
+ assist_state = assist_session.public_state(sid)
+ return support_tickets.build_public_view(updated, infra_status=infra_status, assist_state=assist_state)
+
+
+@router.get("/activity-log")
+def get_activity_log(
+ session_id: str = Depends(bind_onboarding_session),
+ domain: str | None = None,
+):
+ domain_q = (domain or "").strip().lower()
+ if domain_q and len(domain_q) >= 3:
+ entries, sessions_merged = activity_log.get_entries_for_domain(domain_q)
+ return {
+ "session_id": session_id or None,
+ "domain": domain_q,
+ "sessions_merged": sessions_merged,
+ "entries": entries,
+ }
+ if not session_id:
+ return {"entries": [], "session_id": None}
+ return {
+ "session_id": session_id,
+ "entries": activity_log.get_entries(session_id),
+ "sessions_merged": [session_id],
+ }
+
+
+@router.get("/infrastructure/status/{domain}")
+def infrastructure_status(domain: str):
+ return infrastructure.get_status(domain.lower().strip())
+
+
+@router.post("/infrastructure/provision")
+def infrastructure_provision(
+ body: DomainRequest,
+ request: Request,
+ step: str | None = Query(None),
+):
+ get_session_from_request(request)
+ domain = body.domain.lower().strip()
+ activity_log.info(f"Provisionar infra: {domain}", source="traefik")
+ try:
+ return infrastructure.provision(domain, step_id=step)
+ except Exception as e:
+ activity_log.error(str(e), source="traefik")
+ raise HTTPException(400, str(e)) from e
+
+
+@router.post("/validate-domain")
+def validate_domain(body: ValidateDomainRequest, request: Request):
+ get_session_from_request(request)
+ domain = normalize_domain(body.domain)
+ try:
+ validate_primary_domain(domain)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
+ aliases = sanitize_mail_aliases(body.mail_aliases, domain)
+ if body.mail_aliases:
+ domain_registry.save_mail_aliases(domain, aliases)
+ activity_log.info(
+ f"Alias registados: {', '.join(aliases) if aliases else 'nenhum'}",
+ source="portal",
+ )
+ activity_log.info(f"Validar domínio: {domain}", source="portal")
+ exists = carbonio.domain_exists(domain)
+ if exists:
+ activity_log.ok(f"Carbonio: domínio {domain} já existe", source="vm112")
+ else:
+ activity_log.info(
+ f"Carbonio: domínio {domain} ainda não existe (normal — será criado ao «Criar conta agora»)",
+ source="vm112",
+ )
+ cf_auto = False
+ if settings.cloudflare_api_token:
+ if is_cloudflare_sandbox():
+ activity_log.info(
+ "Cloudflare sandbox: token de teste activo (sem chamadas à API real)",
+ source="cloudflare",
+ )
+ cf_auto = True
+ else:
+ try:
+ cf = CloudflareDNS()
+ activity_log.info("Verificar zona Cloudflare…", source="cloudflare")
+ cf_auto = cf.get_zone_by_name(domain) is not None
+ activity_log.ok(
+ f"Cloudflare: zona {'encontrada' if cf_auto else 'ainda não na conta Ligbox'}",
+ source="cloudflare",
+ )
+ except CloudflareError as e:
+ activity_log.warn(f"Cloudflare: {e}", source="cloudflare")
+ else:
+ activity_log.warn("Token Cloudflare não configurado", source="cloudflare")
+
+ return {
+ "domain": domain,
+ "mail_aliases": aliases,
+ "exists_in_carbonio": exists,
+ "carbonio_will_create_domain": not exists,
+ "carbonio_provisioning": True,
+ "cloudflare_auto_dns": cf_auto,
+ "message": (
+ f"O domínio {'já existe' if exists else 'será criado'} no Carbonio ao criar a conta. "
+ f"{'Cloudflare: DNS automático OK.' if cf_auto else 'Cloudflare: configure DNS no passo seguinte.'}"
+ ),
+ }
+
+
+def _cloudflare_zone_status(domain: str) -> dict:
+ domain = domain.lower().strip()
+ if not settings.cloudflare_api_token:
+ return {
+ "domain": domain,
+ "cloudflare_configured": False,
+ "zone_in_account": False,
+ "can_apply_mail_records": False,
+ "zone_status": None,
+ "nameservers": [],
+ "managed_zones": [],
+ "portal_onboarding_required": True,
+ "message": "API Cloudflare não configurada no servidor.",
+ }
+ cf = CloudflareDNS()
+ cf.verify_token()
+ managed = cf.list_zone_names()
+ zone = cf.get_zone_by_name(domain)
+ in_account = zone is not None
+ zone_status = zone.get("status") if zone else None
+ nameservers = wizard_nameservers(zone) if zone else []
+
+ return {
+ "domain": domain,
+ "cloudflare_configured": True,
+ "zone_in_account": in_account,
+ "can_apply_mail_records": in_account,
+ "zone_id": zone["id"] if zone else None,
+ "zone_status": zone_status,
+ "nameservers": nameservers,
+ "managed_zones": managed,
+ "portal_onboarding_required": not in_account,
+ "message": (
+ f"Zona {domain} pronta na Cloudflare Ligbox ({zone_status})."
+ if in_account
+ else f"Adicione {domain} na Cloudflare Ligbox e altere os NS no registrador."
+ ),
+ }
+
+
+@router.get("/dns/cloudflare/status/{domain}")
+def cloudflare_zone_status(domain: str):
+ try:
+ activity_log.info(f"Estado Cloudflare: {domain}", source="cloudflare")
+ return _cloudflare_zone_status(domain.lower().strip())
+ except CloudflareError as e:
+ activity_log.error(str(e), source="cloudflare")
+ raise HTTPException(502, f"Erro Cloudflare: {e}") from e
+
+
+def _portal_onboarding_payload(domain: str, zone_created: bool = False) -> dict:
+ status = _cloudflare_zone_status(domain)
+ nameservers = status.get("nameservers") or []
+ in_account = status.get("zone_in_account")
+
+ if in_account:
+ return {
+ "domain": domain,
+ "ready": True,
+ "zone_created": zone_created,
+ "steps": [
+ {
+ "order": 1,
+ "title": "Domínio na Cloudflare Ligbox",
+ "done": True,
+ "detail": (
+ f"Zona criada automaticamente. Estado: {status.get('zone_status', 'pending')}."
+ if zone_created
+ else f"Zona já existia ({status.get('zone_status', 'active')})."
+ ),
+ },
+ {
+ "order": 2,
+ "title": "Alterar nameservers no registrador",
+ "done": status.get("zone_status") == "active",
+ "detail": "Aponte o domínio para os nameservers Cloudflare listados abaixo.",
+ },
+ {
+ "order": 3,
+ "title": "Registos de email",
+ "done": False,
+ "detail": "Use «Criar apontamentos de email» para MX, A, SPF e DMARC.",
+ },
+ ],
+ "nameservers": nameservers,
+ "status": status,
+ }
+
+ return {
+ "domain": domain,
+ "ready": False,
+ "zone_created": False,
+ "steps": [
+ {
+ "order": 1,
+ "title": "Criar zona na Cloudflare",
+ "done": False,
+ "detail": "Não foi possível criar a zona. Verifique o API token.",
+ },
+ ],
+ "nameservers": [],
+ "status": status,
+ }
+
+
+@router.post("/dns/cloudflare/provision-zone")
+def provision_cloudflare_zone(body: DomainRequest, request: Request):
+ get_session_from_request(request)
+ domain = normalize_domain(body.domain)
+ try:
+ validate_primary_domain(domain)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
+ if not settings.cloudflare_api_token:
+ raise HTTPException(400, _cloudflare_token_missing_error())
+
+ cf = CloudflareDNS()
+ try:
+ activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
+ cf.verify_token()
+ result = cf.ensure_zone(domain)
+ zone = result["zone"]
+ created = result["created"]
+ sandbox = is_cloudflare_sandbox()
+ if sandbox:
+ activity_log.info(
+ "Cloudflare sandbox: zona simulada (token de teste — não cria zona real)",
+ source="cloudflare",
+ )
+ activity_log.ok(
+ f"Zona {domain} {'criada' if created else 'já existia'} (id {zone.get('id', '?')})",
+ source="cloudflare",
+ )
+ payload = _portal_onboarding_payload(domain, zone_created=created)
+ payload["zone_id"] = zone.get("id")
+ payload["sandbox"] = sandbox
+ payload["message"] = (
+ f"Domínio {domain} {'criado' if created else 'já existia'} na Cloudflare Ligbox. "
+ + (
+ "Ambiente de teste: apontamentos simulados automaticamente."
+ if sandbox
+ else "Altere os nameservers no registrador."
+ )
+ )
+ if nameservers := wizard_nameservers(zone):
+ payload["nameservers"] = nameservers
+ payload["status"]["nameservers"] = nameservers
+ if sandbox:
+ zone_id = zone.get("id")
+ applied = []
+ for rec in mail_dns_records(domain, settings.mail_public_ip, []):
+ upsert = cf.upsert_record(
+ zone_id,
+ rec["type"],
+ rec["name"],
+ rec["content"],
+ priority=rec.get("priority"),
+ proxied=rec.get("proxied", False),
+ zone_name=domain,
+ )
+ applied.append({"type": rec["type"], "name": rec["name"], "id": upsert.get("id")})
+ verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
+ payload["applied"] = applied
+ payload["verification"] = verification
+ for step in payload.get("steps") or []:
+ if step.get("order") == 3:
+ step["done"] = True
+ step["detail"] = "Apontamentos simulados (sandbox)."
+ activity_log.ok("Cloudflare sandbox: apontamentos de email simulados", source="cloudflare")
+ return payload
+ except CloudflareError as e:
+ activity_log.error(f"Provision zona falhou: {e}", source="cloudflare")
+ raise HTTPException(
+ status_code=502,
+ detail=cloudflare_error_http_detail(e, domain),
+ ) from e
+
+
+@router.get("/dns/portal-onboarding/{domain}")
+def portal_dns_onboarding(domain: str):
+ domain = domain.lower().strip()
+ try:
+ return _portal_onboarding_payload(domain, zone_created=False)
+ except CloudflareError as e:
+ activity_log.error(str(e), source="cloudflare")
+ raise HTTPException(502, f"Erro Cloudflare: {e}") from e
+
+
+@router.get("/dns/instructions/{domain}")
+def dns_instructions(domain: str):
+ domain = domain.lower().strip()
+ mail_host = f"mail.{domain}"
+ ip = settings.mail_public_ip
+ activity_log.info(f"Instruções DNS para {domain}", source="portal")
+ cf_status = None
+ try:
+ cf_status = _cloudflare_zone_status(domain)
+ except CloudflareError:
+ pass
+ return {
+ "domain": domain,
+ "mail_public_ip": ip,
+ "cloudflare": cf_status,
+ "dns_paths": {
+ "portal": "Trazer DNS para o portal (Cloudflare conta Ligbox)",
+ "external": "Manter DNS no provedor atual (apontamentos manuais)",
+ },
+ "records": [
+ {"type": "A", "name": mail_host, "value": ip, "note": "DNS only (sem proxy Cloudflare)"},
+ {"type": "MX", "name": domain, "value": mail_host, "priority": 10},
+ {"type": "TXT", "name": domain, "value": f"v=spf1 mx a:{mail_host} ip4:{ip} -all"},
+ {
+ "type": "TXT",
+ "name": f"_dmarc.{domain}",
+ "value": f"v=DMARC1; p=quarantine; rua=mailto:postmaster@{domain}",
+ },
+ {
+ "type": "TXT",
+ "name": f"default._domainkey.{domain}",
+ "value": "(DKIM — Carbonio Admin após criar domínio)",
+ },
+ ],
+ "cloudflare_hint": "Escolha no passo DNS: trazer para o portal ou manter no provedor atual.",
+ }
+
+
+@router.get("/dns/verify/{domain}")
+def verify_dns(domain: str):
+ domain = domain.lower().strip()
+ activity_log.info(f"Verificar DNS público: {domain}", source="portal")
+ result = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
+ if result.get("ready"):
+ activity_log.ok("DNS público: MX e A mail OK", source="portal")
+ else:
+ activity_log.warn("DNS público: ainda incompleto (MX ou A mail)", source="portal")
+ return result
+
+
+@router.post("/dns/cloudflare/apply")
+def apply_cloudflare_dns(body: CloudflareApplyRequest, request: Request):
+ get_session_from_request(request)
+ domain = body.domain.lower().strip()
+ if not settings.cloudflare_api_token:
+ raise HTTPException(400, _cloudflare_token_missing_error())
+
+ cf = CloudflareDNS()
+ try:
+ activity_log.info(f"Aplicar registos mail na Cloudflare: {domain}", source="cloudflare")
+ cf.verify_token()
+ except CloudflareError as e:
+ activity_log.error(str(e), source="cloudflare")
+ raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e
+
+ zone_id = body.zone_id
+ if not zone_id:
+ zone = cf.get_zone_by_name(domain)
+ if not zone:
+ activity_log.error(f"Zona {domain} não está na conta Ligbox", source="cloudflare")
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "code": "zone_not_in_account",
+ "domain": domain,
+ "message": f"Zona {domain} ainda não está na Cloudflare Ligbox.",
+ "use_portal_onboarding": True,
+ },
+ )
+ zone_id = zone["id"]
+
+ rec = domain_registry.get_domain_record(domain)
+ aliases = sanitize_mail_aliases(body.mail_aliases, domain) if body.mail_aliases else []
+ if not aliases and rec:
+ aliases = list(rec.get("mail_aliases") or [])
+
+ applied = []
+ for rec in mail_dns_records(domain, settings.mail_public_ip, aliases):
+ result = cf.upsert_record(
+ zone_id,
+ rec["type"],
+ rec["name"],
+ rec["content"],
+ priority=rec.get("priority"),
+ proxied=rec.get("proxied", False),
+ zone_name=domain,
+ )
+ applied.append({"type": rec["type"], "name": rec["name"], "id": result.get("id")})
+ activity_log.ok(f"DNS {rec['type']} {rec['name']} → {rec['content']}", source="cloudflare")
+
+ verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
+ return {"domain": domain, "zone_id": zone_id, "applied": applied, "verification": verification}
+
+
+@router.get("/session/password-status")
+def session_password_status(request: Request):
+ """Spec 016b — indica se a senha do cadastro está no vault servidor (sem expor valor)."""
+ sid = get_session_from_request(request)
+ if not sid:
+ return {"ready": False, "source": None}
+ ready = onboard_handoff.session_has_password(sid)
+ return {"ready": ready, "source": "portal_handoff" if ready else None}
+
+
+@router.delete("/session/password")
+def clear_session_password(request: Request):
+ """Remove senha do vault (ex.: utilizador escolhe senha diferente no wizard)."""
+ sid = get_session_from_request(request)
+ if sid:
+ onboard_handoff.clear_session_password(sid)
+ return {"ok": True}
+
+
+@router.post("/account/create")
+def create_account(body: CreateAccountRequest, request: Request):
+ sid = get_session_from_request(request)
+ domain = body.domain.lower().strip()
+ email = f"{body.local_part}@{domain}"
+ webmail = f"https://mail.{domain}/"
+ dns_mode = body.dns_mode or "não indicado"
+ aliases = sanitize_mail_aliases(body.mail_aliases, domain)
+ if aliases:
+ domain_registry.save_mail_aliases(domain, aliases)
+
+ if body.use_server_password:
+ password = onboard_handoff.pop_password_for_session(sid) if sid else None
+ if not password:
+ raise HTTPException(
+ 400,
+ "Senha do cadastro expirou ou não está disponível. Defina a senha manualmente no passo Conta admin.",
+ )
+ else:
+ password = (body.password or "").strip()
+ if len(password) < 8:
+ raise HTTPException(400, "A senha precisa de pelo menos 8 caracteres.")
+ if sid:
+ onboard_handoff.clear_session_password(sid)
+
+ activity_log.info(f"Iniciar criação de conta: {email}", source="portal")
+
+ try:
+ out, account_reused = carbonio.ensure_onboarding_account(
+ email, password, body.display_name
+ )
+ if account_reused:
+ activity_log.info(
+ f"Conta reutilizada (retry wizard): {email} — senha actualizada",
+ source="vm112",
+ )
+ else:
+ activity_log.ok(f"zmprov ca concluído: {out or 'OK'}", source="vm112")
+ except carbonio.CarbonioError as e:
+ activity_log.error(f"Criação falhou: {e}", source="vm112")
+ raise HTTPException(400, str(e)) from e
+
+ verified = carbonio.account_exists(email)
+ if verified:
+ activity_log.ok(f"Verificação: conta {email} existe no Carbonio", source="vm112")
+ domain_registry.register_portal_admin(domain, email, mail_aliases=aliases)
+ activity_log.ok(f"Painel admin: {email} autorizado para {domain}", source="portal")
+ if aliases:
+ carbonio.add_mail_alias_hostnames(domain, aliases)
+ activity_log.ok(f"Alias Carbonio: {len(aliases)} hostname(s)", source="vm112")
+ else:
+ activity_log.error(
+ f"ALERTA: zmprov ca executou mas conta {email} NÃO encontrada (ga)",
+ source="vm112",
+ )
+
+ verification = None
+ try:
+ verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
+ except Exception as e:
+ activity_log.warn(f"Verificação DNS: {e}", source="portal")
+
+ notifications_sent = {
+ "admin": notifications.notify_admin_onboarding(
+ domain,
+ email,
+ dns_mode,
+ verified,
+ verification.get("ready") if verification else None,
+ ),
+ "client": False,
+ "welcome": False,
+ }
+
+ if body.notify_email:
+ notifications_sent["client"] = notifications.notify_client_contact(
+ str(body.notify_email),
+ domain,
+ email,
+ webmail,
+ verified,
+ )
+
+ if body.send_welcome and verified:
+ notifications_sent["welcome"] = notifications.send_welcome_to_mailbox(
+ email,
+ domain,
+ webmail,
+ body.display_name,
+ )
+
+ activity_log.ok("Processo de onboarding finalizado", source="portal")
+
+ infra_status = None
+ try:
+ infra_status = infrastructure.provision(domain, mail_aliases=aliases)
+ activity_log.ok("Provisionamento infra (Traefik/cert/SNI) concluído", source="traefik")
+ except Exception as e:
+ activity_log.warn(f"Infra automática: {e} — use a coluna Infraestrutura", source="traefik")
+
+ return {
+ "email": email,
+ "message": out,
+ "account_reused": account_reused,
+ "webmail": webmail,
+ "account_verified": verified,
+ "notifications_sent": notifications_sent,
+ "client_settings": _client_settings(domain, email),
+ "dns_verification": verification,
+ "needs_review": not verified,
+ "mail_aliases": aliases,
+ "infrastructure": infra_status.get("status") if infra_status else infrastructure.get_status(domain, aliases),
+ }
diff --git a/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/FinishToolbar.jsx b/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/FinishToolbar.jsx
new file mode 100644
index 0000000..469f36a
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/FinishToolbar.jsx
@@ -0,0 +1,32 @@
+export default function FinishToolbar({ webmailUrl, adminUrl, pending, onFinish }) {
+ return (
+
+ {pending && (
+
+ Ativando webmail em segundo plano…
+
+ )}
+
+
+ )
+}
diff --git a/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/clientSettings.js b/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/clientSettings.js
new file mode 100644
index 0000000..11ff679
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/clientSettings.js
@@ -0,0 +1,11 @@
+/** Texto canónico — não depender de sessões antigas em cache. */
+export const CLIENT_SMTP_NOTE =
+ 'Use a porta 465 com SSL/TLS para envio. Não use a porta 587.'
+
+export function normalizeClientSettings(raw) {
+ if (!raw || typeof raw !== 'object') return null
+ const note = String(raw.smtp_note || '')
+ const legacy =
+ /partilhad/i.test(note) || /ibytera/i.test(note) || /587.*domínio/i.test(note)
+ return legacy ? { ...raw, smtp_note: CLIENT_SMTP_NOTE } : raw
+}
diff --git a/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/main-wizard.jsx b/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/main-wizard.jsx
new file mode 100644
index 0000000..95a0192
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm112-wizard/frontend/src/main-wizard.jsx
@@ -0,0 +1,57 @@
+import React from "react"
+import ReactDOM from "react-dom/client"
+import App from "./App"
+import ClientTicketPortal from "./ClientTicketPortal.jsx"
+import DomainAdmin from "./DomainAdmin.jsx"
+import ForgotDomainPassword from "./ForgotDomainPassword.jsx"
+import "./styles.css"
+import "./wizard-setup.css"
+import "./assist-asm.css"
+import "./client-ticket-portal.css"
+import { initTelemetry } from "./lib/telemetry"
+
+initTelemetry()
+
+function parseAssistPath() {
+ const m = window.location.pathname.replace(/\/$/, "").match(/^\/assist\/([^/]+)/)
+ return m ? m[1] : null
+}
+
+const urlParams = new URLSearchParams(window.location.search)
+const ticketFromUrl = urlParams.get('ticket')?.trim()?.toUpperCase()
+const sessionFromUrl = urlParams.get('session')?.trim()
+const assistSessionId = parseAssistPath()
+const assistTakeoverToken = new URLSearchParams(window.location.search).get("token")
+
+const rootEl = document.getElementById('root')
+const path = window.location.pathname.replace(/\/$/, '') || '/'
+
+if (path === '/admin/forgot-password') {
+ ReactDOM.createRoot(rootEl).render(
+
+
+ ,
+ )
+} else if (path === '/admin' || path.startsWith('/admin/')) {
+ ReactDOM.createRoot(rootEl).render(
+
+
+ ,
+ )
+} else if (ticketFromUrl && !assistSessionId) {
+ ReactDOM.createRoot(rootEl).render(
+
+
+ ,
+ )
+} else {
+ ReactDOM.createRoot(rootEl).render(
+
+
+ ,
+)
+}
diff --git a/ligbox-ops-platform/deploy/vm122-desk/README.md b/ligbox-ops-platform/deploy/vm122-desk/README.md
new file mode 100644
index 0000000..dbe5f3b
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm122-desk/README.md
@@ -0,0 +1,21 @@
+# Deploy mirror — Desk VM122 (2026-06-19)
+
+Espelho parcial dos ficheiros alterados em produção na **VM122** (`/opt/ligbox-ops-platform`).
+
+## Alterações desta entrega
+
+| Ficheiro | Alteração |
+|----------|-----------|
+| `frontend/assets/app.js` | Card tenant: **Ligbox Datacenter — Node VM001** |
+| `api/app/audit_store.py` | Sync `audit_domains` alargado (eventos onboarding recentes) |
+| `api/app/main.py` | Upsert nome tenant no bootstrap; seed VM001 |
+
+## Aplicar na VM122
+
+```bash
+cd /opt/ligbox-ops-platform
+docker-compose -f docker-compose.mvp.yml build api frontend
+docker-compose -f docker-compose.mvp.yml up -d api frontend
+```
+
+**Roger · VM130 spec-hub · 2026-06-19**
diff --git a/ligbox-ops-platform/deploy/vm122-desk/api/app/audit_store.py b/ligbox-ops-platform/deploy/vm122-desk/api/app/audit_store.py
new file mode 100644
index 0000000..05979ed
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm122-desk/api/app/audit_store.py
@@ -0,0 +1,518 @@
+"""SQLite persistence for audit domains and checks."""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+from datetime import datetime, timezone
+from typing import Any
+
+from app.collectors.base import CHECK_LABELS
+
+ONBOARD_DOMAIN_EVENTS = frozenset({
+ "onboarding.started",
+ "domain.validated",
+ "dns.applied",
+ "account.created",
+ "infra.synced",
+ "onboarding.completed",
+ "onboarding.escalated",
+ "onboarding.failed",
+ "webmail.released",
+})
+TENANT_ONBOARD = 1
+
+TENANT_WEBHOOK_SOURCE = {
+ 1: "vm112-onboard",
+ 2: "wazuh",
+}
+
+FUNNEL_EVENT_RANK = {
+ "onboarding.started": 1,
+ "domain.validated": 2,
+ "dns.applied": 3,
+ "account.created": 4,
+ "infra.synced": 5,
+ "onboarding.completed": 6,
+ "company.validated": 7,
+ "webmail.released": 8,
+ "onboarding.failed": 99,
+}
+
+FUNNEL_STAGE_BY_RANK = {
+ 1: "started",
+ 2: "domain_validated",
+ 3: "dns_applied",
+ 4: "account_created",
+ 5: "infra_synced",
+ 6: "completed",
+ 7: "company_validated",
+ 8: "webmail_released",
+ 99: "failed",
+}
+
+FUNNEL_STAGE_LABELS = {
+ "started": "Iniciado",
+ "domain_validated": "Domínio OK",
+ "dns_applied": "DNS aplicado",
+ "account_created": "Conta criada",
+ "infra_synced": "Infra sync",
+ "completed": "Concluído",
+ "company_validated": "Empresa validada",
+ "webmail_released": "Webmail liberado",
+ "failed": "Falhou",
+ "registered": "Registado",
+ "unknown": "Sem dados",
+}
+
+STATUS_RANK = {"pass": 0, "skip": 1, "warn": 2, "error": 3, "fail": 4}
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _parse_payload(raw: str | None) -> dict:
+ if not raw:
+ return {}
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError:
+ return {}
+
+
+def init_audit_schema(conn: sqlite3.Connection) -> None:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS audit_domains (
+ id INTEGER PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ domain TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'onboarding',
+ created_at TEXT NOT NULL,
+ UNIQUE(tenant_id, domain)
+ );
+ CREATE TABLE IF NOT EXISTS audit_checks (
+ id INTEGER PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ domain TEXT NOT NULL,
+ check_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ message TEXT,
+ evidence TEXT,
+ checked_at TEXT NOT NULL,
+ UNIQUE(tenant_id, domain, check_id)
+ );
+ """)
+
+
+def sync_domains_from_webhooks(conn: sqlite3.Connection) -> int:
+ rows = conn.execute(
+ """
+ SELECT event_type, payload FROM webhook_events
+ WHERE source = 'vm112-onboard'
+ ORDER BY id DESC LIMIT 500
+ """
+ ).fetchall()
+ added = 0
+ now = _now()
+ seen: set[tuple[int, str]] = set()
+ for row in rows:
+ if row["event_type"] not in ONBOARD_DOMAIN_EVENTS:
+ continue
+ payload = _parse_payload(row["payload"])
+ domain = (payload.get("domain") or "").strip().lower()
+ if not domain or len(domain) < 3:
+ continue
+ key = (TENANT_ONBOARD, domain)
+ if key in seen:
+ continue
+ seen.add(key)
+ cur = conn.execute(
+ """
+ INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
+ VALUES (?, ?, 'onboarding', ?)
+ """,
+ (TENANT_ONBOARD, domain, now),
+ )
+ if cur.rowcount:
+ added += 1
+ conn.commit()
+ return added
+
+
+def list_audit_domains(conn: sqlite3.Connection, tenant_id: int | None = None) -> list[dict]:
+ if tenant_id:
+ rows = conn.execute(
+ "SELECT tenant_id, domain, source, created_at FROM audit_domains WHERE tenant_id = ? ORDER BY domain",
+ (tenant_id,),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT tenant_id, domain, source, created_at FROM audit_domains ORDER BY tenant_id, domain"
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+def upsert_check(
+ conn: sqlite3.Connection,
+ tenant_id: int,
+ domain: str,
+ check_id: str,
+ status: str,
+ message: str,
+ evidence: dict | None,
+ checked_at: str | None = None,
+) -> None:
+ conn.execute(
+ """
+ INSERT INTO audit_checks (tenant_id, domain, check_id, status, message, evidence, checked_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(tenant_id, domain, check_id) DO UPDATE SET
+ status = excluded.status,
+ message = excluded.message,
+ evidence = excluded.evidence,
+ checked_at = excluded.checked_at
+ """,
+ (
+ tenant_id,
+ domain.lower(),
+ check_id,
+ status,
+ message,
+ json.dumps(evidence or {}),
+ checked_at or _now(),
+ ),
+ )
+
+
+def get_checks(conn: sqlite3.Connection, tenant_id: int, domain: str) -> list[dict]:
+ rows = conn.execute(
+ """
+ SELECT check_id, status, message, evidence, checked_at
+ FROM audit_checks WHERE tenant_id = ? AND domain = ?
+ ORDER BY check_id
+ """,
+ (tenant_id, domain.lower()),
+ ).fetchall()
+ out = []
+ for row in rows:
+ item = dict(row)
+ item["label"] = CHECK_LABELS.get(item["check_id"], item["check_id"])
+ item["evidence"] = _parse_payload(item.get("evidence"))
+ out.append(item)
+ return out
+
+
+def aggregate_score(checks: list[dict]) -> dict[str, Any]:
+ total = len(CHECK_LABELS)
+ counts = {"pass": 0, "warn": 0, "fail": 0, "error": 0, "skip": 0}
+ worst = "pass"
+ for c in checks:
+ st = c.get("status") or "skip"
+ counts[st] = counts.get(st, 0) + 1
+ if STATUS_RANK.get(st, 0) > STATUS_RANK.get(worst, 0):
+ worst = st
+ if worst in ("fail", "error"):
+ overall = "critical"
+ elif worst == "warn":
+ overall = "degraded"
+ elif checks:
+ overall = "healthy"
+ else:
+ overall = "unknown"
+ return {
+ "pass": counts.get("pass", 0),
+ "warn": counts.get("warn", 0),
+ "fail": counts.get("fail", 0),
+ "error": counts.get("error", 0),
+ "skip": counts.get("skip", 0),
+ "total": total,
+ "overall_status": overall,
+ }
+
+
+def tenant_overview(conn: sqlite3.Connection, tenant_id: int, name: str, ip: str) -> dict:
+ if tenant_id == 2:
+ from app.modules import store as module_store
+
+ if module_store.is_module_enabled("wazuh-soc"):
+ from app.wazuh_soc_store import wazuh_tenant_overview
+
+ return wazuh_tenant_overview(conn, tenant_id, name, ip)
+ domains = list_audit_domains(conn, tenant_id)
+ if not domains:
+ return {
+ "tenant_id": tenant_id,
+ "name": name,
+ "ip": ip,
+ "status": "unknown",
+ "score": {"pass": 0, "warn": 0, "fail": 0, "total": 8},
+ "domains_count": 0,
+ "last_audit_at": None,
+ "top_issues": [],
+ }
+
+ all_checks: list[dict] = []
+ last_audit = None
+ top_issues: list[dict] = []
+ domain_scores: list[dict] = []
+ for d in domains:
+ checks = get_checks(conn, tenant_id, d["domain"])
+ if not checks:
+ continue
+ all_checks.extend(checks)
+ domain_scores.append(aggregate_score(checks))
+ for c in checks:
+ if c["checked_at"] and (not last_audit or c["checked_at"] > last_audit):
+ last_audit = c["checked_at"]
+ if c["status"] in ("fail", "error", "warn"):
+ top_issues.append({
+ "domain": d["domain"],
+ "check_id": c["check_id"],
+ "status": c["status"],
+ "message": c.get("message"),
+ })
+
+ if domain_scores:
+ worst = max(domain_scores, key=lambda s: STATUS_RANK.get(s["overall_status"], 0))
+ score = worst
+ else:
+ score = aggregate_score(all_checks)
+ return {
+ "tenant_id": tenant_id,
+ "name": name,
+ "ip": ip,
+ "status": score["overall_status"],
+ "score": {
+ "pass": score["pass"],
+ "warn": score["warn"],
+ "fail": score["fail"] + score["error"],
+ "total": score["total"],
+ },
+ "domains_count": len(domains),
+ "last_audit_at": last_audit,
+ "top_issues": top_issues[:5],
+ }
+
+
+def build_overview(conn: sqlite3.Connection) -> dict:
+ tenants = conn.execute("SELECT id, name, ip FROM tenants ORDER BY id").fetchall()
+ return {
+ "generated_at": _now(),
+ "tenants": [tenant_overview(conn, t["id"], t["name"], t["ip"]) for t in tenants],
+ }
+
+
+def scorecard(conn: sqlite3.Connection, tenant_id: int, domain: str) -> dict:
+ domain = domain.lower().strip()
+ checks = get_checks(conn, tenant_id, domain)
+ score = aggregate_score(checks)
+ return {
+ "tenant_id": tenant_id,
+ "domain": domain,
+ "checked_at": max((c["checked_at"] for c in checks), default=None),
+ "overall_status": score["overall_status"],
+ "checks": checks,
+ }
+
+
+def _extract_client_ip(payload: dict, data: dict | None = None) -> str | None:
+ data = data or {}
+ for key in ("client_ip", "user_ip", "remote_ip", "srcip", "ip", "agent_ip"):
+ val = data.get(key) or payload.get(key)
+ if val:
+ return str(val)
+ ingress = payload.get("ingress_client_ip")
+ return str(ingress) if ingress else None
+
+
+def _funnel_stage_from_events(events: list[dict]) -> str:
+ best_rank = 0
+ for ev in events:
+ rank = FUNNEL_EVENT_RANK.get(ev.get("event") or "", 0)
+ if rank > best_rank:
+ best_rank = rank
+ if best_rank:
+ return FUNNEL_STAGE_BY_RANK.get(best_rank, "unknown")
+ return "registered"
+
+
+def _execution_status(events: list[dict]) -> str:
+ types = {ev.get("event") for ev in events}
+ if "onboarding.failed" in types:
+ return "failed"
+ if "onboarding.completed" in types:
+ return "completed"
+ if types & set(FUNNEL_EVENT_RANK):
+ return "in_progress"
+ if events:
+ return "in_progress"
+ return "registered"
+
+
+def _tickets_for_domain(conn: sqlite3.Connection, domain: str) -> list[dict]:
+ dom = domain.lower().strip()
+ rows = conn.execute(
+ """
+ SELECT id, subject, status, session_id, payload, created_at
+ FROM tickets ORDER BY id DESC LIMIT 500
+ """
+ ).fetchall()
+ out = []
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ if (payload.get("domain") or "").strip().lower() != dom:
+ continue
+ data = payload.get("data") or {}
+ out.append({
+ "ticket_id": row["id"],
+ "status": row["status"],
+ "subject": row["subject"],
+ "session_id": row["session_id"] or payload.get("session_id"),
+ "email": data.get("email") or payload.get("account_email"),
+ "crm_track": payload.get("crm_track"),
+ "created_at": row["created_at"],
+ })
+ return out
+
+
+def _domain_webhook_events(conn: sqlite3.Connection, source: str | None, domain: str) -> list[dict]:
+ if not source:
+ return []
+ dom = domain.lower().strip()
+ rows = conn.execute(
+ """
+ SELECT event_type, payload, created_at FROM webhook_events
+ WHERE source = ?
+ ORDER BY created_at ASC
+ """,
+ (source,),
+ ).fetchall()
+ events = []
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ if (payload.get("domain") or "").strip().lower() != dom:
+ continue
+ data = payload.get("data") or {}
+ client_ip = _extract_client_ip(payload, data)
+ detail = data.get("step") or data.get("description") or data.get("agent")
+ if source == "wazuh" and not client_ip:
+ client_ip = data.get("agent_ip") or data.get("srcip")
+ events.append({
+ "event": row["event_type"],
+ "at": row["created_at"],
+ "session_id": payload.get("session_id"),
+ "email": data.get("email"),
+ "client_ip": client_ip,
+ "detail": detail,
+ })
+ return events
+
+
+def _domain_detail(conn: sqlite3.Connection, tenant_id: int, domain_row: dict) -> dict:
+ domain = domain_row["domain"]
+ checks = get_checks(conn, tenant_id, domain)
+ score = aggregate_score(checks)
+ issues = [
+ {
+ "check_id": c["check_id"],
+ "label": c.get("label") or CHECK_LABELS.get(c["check_id"], c["check_id"]),
+ "status": c["status"],
+ "message": c.get("message"),
+ "checked_at": c.get("checked_at"),
+ "evidence": c.get("evidence") or {},
+ }
+ for c in checks
+ if c.get("status") in ("fail", "error", "warn")
+ ]
+ source = TENANT_WEBHOOK_SOURCE.get(tenant_id)
+ timeline = _domain_webhook_events(conn, source, domain)
+ tickets = _tickets_for_domain(conn, domain)
+ ticket = tickets[0] if tickets else None
+ funnel_stage = _funnel_stage_from_events(timeline)
+ execution_status = _execution_status(timeline)
+ client_ips = sorted({ev["client_ip"] for ev in timeline if ev.get("client_ip")})
+ last_event = timeline[-1] if timeline else None
+ started_at = timeline[0]["at"] if timeline else domain_row.get("created_at")
+ return {
+ "domain": domain,
+ "source": domain_row.get("source"),
+ "registered_at": domain_row.get("created_at"),
+ "email": (last_event or {}).get("email") or (ticket or {}).get("email"),
+ "session_id": (last_event or {}).get("session_id") or (ticket or {}).get("session_id"),
+ "client_ip": client_ips[-1] if client_ips else None,
+ "client_ips": client_ips,
+ "funnel_stage": funnel_stage,
+ "funnel_stage_label": FUNNEL_STAGE_LABELS.get(funnel_stage, funnel_stage),
+ "execution_status": execution_status,
+ "last_event": (last_event or {}).get("event"),
+ "last_event_at": (last_event or {}).get("at"),
+ "started_at": started_at,
+ "audit_status": score["overall_status"],
+ "score": {
+ "pass": score["pass"],
+ "warn": score["warn"],
+ "fail": score["fail"] + score["error"],
+ "total": score["total"],
+ },
+ "issue_count": len(issues),
+ "issues": issues,
+ "ticket_id": (ticket or {}).get("ticket_id"),
+ "ticket_status": (ticket or {}).get("status"),
+ "tickets_count": len(tickets),
+ "timeline": timeline,
+ "last_audit_at": max((c["checked_at"] for c in checks), default=None),
+ }
+
+
+def _apply_funnel_timing_to_domains(domain_details: list[dict]) -> None:
+ from app.funnel_timing import apply_module_timing
+
+ for domain in domain_details:
+ timeline = domain.get("timeline") or []
+ if not timeline:
+ continue
+ enriched, timing_meta = apply_module_timing(timeline)
+ domain["timeline"] = enriched
+ if timing_meta:
+ domain["timing"] = timing_meta
+
+
+def tenant_details(conn: sqlite3.Connection, tenant_id: int) -> dict | None:
+ row = conn.execute("SELECT id, name, ip FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
+ if not row:
+ return None
+ if tenant_id == 2:
+ from app.modules import store as module_store
+
+ if module_store.is_module_enabled("wazuh-soc"):
+ from app.wazuh_soc_store import wazuh_tenant_details
+
+ return wazuh_tenant_details(conn, tenant_id, row["name"], row["ip"])
+ domains = list_audit_domains(conn, tenant_id)
+ domain_details = [_domain_detail(conn, tenant_id, d) for d in domains]
+ _apply_funnel_timing_to_domains(domain_details)
+ summary = {
+ "domains_total": len(domain_details),
+ "in_progress": sum(1 for d in domain_details if d["execution_status"] == "in_progress"),
+ "completed": sum(1 for d in domain_details if d["execution_status"] == "completed"),
+ "failed": sum(1 for d in domain_details if d["execution_status"] == "failed"),
+ "registered": sum(1 for d in domain_details if d["execution_status"] == "registered"),
+ "with_issues": sum(1 for d in domain_details if d["issue_count"] > 0),
+ }
+ result = {
+ "tenant_id": tenant_id,
+ "name": row["name"],
+ "ip": row["ip"],
+ "generated_at": _now(),
+ "summary": summary,
+ "domains": domain_details,
+ }
+ if tenant_id == 1:
+ from app.modules import store as module_store
+
+ if module_store.is_module_enabled("wizard-security"):
+ from app import security_store
+
+ result["security"] = security_store.build_summary(conn, window_hours=24)
+ return result
diff --git a/ligbox-ops-platform/deploy/vm122-desk/api/app/main.py b/ligbox-ops-platform/deploy/vm122-desk/api/app/main.py
new file mode 100644
index 0000000..4b278e2
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm122-desk/api/app/main.py
@@ -0,0 +1,1290 @@
+import json
+import os
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import httpx
+import redis
+from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+
+from app import audit_store, auth, assist_store, live_presence, push_service
+from app.auth_routes import router as auth_router
+from app.registration_routes import router as registration_router
+from app.mfa_recovery_routes import router as mfa_recovery_router
+from app.assist_routes import router as assist_router, process_escalation_webhook
+from app.crm_routes import router as crm_router
+from app import crm_leads, integration_health
+from app.cloudflare_dns import fetch_domain_dns
+from app.modules.routes import router as modules_router
+from app.vm112_domains_routes import router as vm112_domains_router
+from app.carbonio_release_routes import router as carbonio_release_router
+from app.migration.router import router as migration_router
+from app.billing_routes import router as billing_router
+from app.vm123 import vm123_router
+from app.agents.routes import router as agents_router
+from app.security_routes import router as security_router
+from app.collectors.base import run_audit
+from app.permissions import (
+ can_assign_ticket,
+ can_list_webhook_events,
+ can_patch_ticket,
+ can_read_audit_overview,
+ can_read_audit_scorecard,
+ can_read_cloudflare_dns,
+ can_read_funnel,
+ can_read_session_timeline,
+ can_read_tickets,
+ can_run_audit,
+ should_mask_sensitive,
+)
+
+DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db"))
+REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
+VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
+MAIL_PUBLIC_IP = os.getenv("MAIL_PUBLIC_IP", "")
+AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600"))
+WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "ligbox-ops-dev-secret")
+WAZUH_WEBHOOK_SECRET = os.getenv("WAZUH_WEBHOOK_SECRET", "ligbox-wazuh-dev-secret")
+WAZUH_MIN_TICKET_LEVEL = int(os.getenv("WAZUH_MIN_TICKET_LEVEL", "10"))
+
+INTEGRATION_SECRETS = {
+ "onboard": WEBHOOK_SECRET,
+ "security": WEBHOOK_SECRET,
+ "wazuh": WAZUH_WEBHOOK_SECRET,
+}
+
+INTEGRATION_SOURCES = {
+ "onboard": "vm112-onboard",
+ "security": "vm112-security",
+ "wazuh": "wazuh",
+}
+
+TICKET_EVENTS_BY_SOURCE = {
+ # Ticket no início do onboarding (email+senha / criar servidor) — Roger 2026-06-10
+ "vm112-onboard": frozenset({"onboarding.started", "onboarding.failed"}),
+ "wazuh": frozenset({"wazuh.alert"}),
+}
+
+TENANT_BY_SOURCE = {
+ "vm112-onboard": 1,
+ "wazuh": 2,
+}
+
+ONBOARD_SOURCE = "vm112-onboard"
+
+FUNNEL_EVENT_RANK = {
+ "session.started": 0,
+ "onboarding.started": 1,
+ "domain.validated": 2,
+ "dns.applied": 3,
+ "account.created": 4,
+ "infra.synced": 5,
+ "onboarding.completed": 6,
+ "company.validated": 7,
+ "webmail.released": 8,
+ "onboarding.failed": 99,
+}
+
+FUNNEL_STAGE_BY_RANK = {
+ 1: "started",
+ 2: "domain_validated",
+ 3: "dns_applied",
+ 4: "account_created",
+ 5: "infra_synced",
+ 6: "completed",
+ 7: "company_validated",
+ 8: "webmail_released",
+ 99: "failed",
+}
+
+FUNNEL_NOTE_EVENTS = frozenset({
+ "account.created",
+ "domain.validated",
+ "dns.applied",
+ "infra.synced",
+ "onboarding.completed",
+ "company.validated",
+ "webmail.released",
+})
+
+ASSIST_ESCALATION_EVENTS = frozenset({"onboarding.escalated", "onboarding.failed"})
+ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.assist.ended"})
+
+TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
+
+app = FastAPI(title="Ligbox Ops Platform API", version="0.9.0-desk-assist")
+app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
+app.include_router(auth_router)
+app.include_router(registration_router)
+app.include_router(mfa_recovery_router)
+app.include_router(assist_router)
+app.include_router(crm_router)
+app.include_router(modules_router)
+app.include_router(vm112_domains_router)
+app.include_router(security_router)
+app.include_router(carbonio_release_router)
+app.include_router(migration_router)
+app.include_router(billing_router)
+app.include_router(vm123_router)
+app.include_router(agents_router)
+
+TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
+
+
+def db():
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(DB_PATH, timeout=30.0)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA busy_timeout=30000")
+ conn.execute("PRAGMA synchronous=NORMAL")
+ return conn
+
+
+def init_db():
+ with db() as conn:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS tenants (
+ id INTEGER PRIMARY KEY, name TEXT NOT NULL, ip TEXT NOT NULL,
+ role TEXT NOT NULL, created_at TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS tickets (
+ id INTEGER PRIMARY KEY, tenant_id INTEGER, subject TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open', payload TEXT, created_at TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS webhook_events (
+ id INTEGER PRIMARY KEY, event_type TEXT NOT NULL, source TEXT NOT NULL,
+ payload TEXT, created_at TEXT NOT NULL);
+ """)
+ now = datetime.now(timezone.utc).isoformat()
+ defaults = [
+ (1, "Ligbox Datacenter — Node VM001", "10.10.10.112", "onboarding_portal"),
+ (2, "VM104 Wazuh SOC", "10.10.10.104", "security_monitoring"),
+ ]
+ for tid, name, ip, role in defaults:
+ if conn.execute("SELECT COUNT(*) c FROM tenants WHERE id = ?", (tid,)).fetchone()["c"] == 0:
+ conn.execute(
+ "INSERT INTO tenants (id,name,ip,role,created_at) VALUES (?,?,?,?,?)",
+ (tid, name, ip, role, now),
+ )
+ else:
+ conn.execute(
+ "UPDATE tenants SET name = ?, ip = ?, role = ? WHERE id = ?",
+ (name, ip, role, tid),
+ )
+ audit_store.init_audit_schema(conn)
+ auth.init_auth_schema(conn)
+ assist_store.init_assist_schema(conn)
+ from app import carbonio_release_store
+
+ carbonio_release_store.init_schema(conn)
+ from app.migration import store as migration_store
+ from app import billing_store
+ migration_store.init_schema(conn)
+ billing_store.init_schema(conn)
+ from app.vm123 import provision_store as vm123_provision_store
+
+ vm123_provision_store.init_schema(conn)
+ from app.agents import store as agent_store
+
+ agent_store.init_agent_schema(conn)
+ conn.commit()
+
+
+def _run_audit_for_domain(tenant_id: int, domain: str) -> dict:
+ now = datetime.now(timezone.utc).isoformat()
+ results = run_audit(
+ tenant_id,
+ domain,
+ vm112_api=VM112_API,
+ mail_public_ip=MAIL_PUBLIC_IP or None,
+ )
+ with db() as conn:
+ for check_id, item in results.items():
+ audit_store.upsert_check(
+ conn,
+ tenant_id,
+ domain,
+ check_id,
+ item.get("status", "error"),
+ item.get("message", ""),
+ item.get("evidence"),
+ now,
+ )
+ conn.commit()
+ return {"tenant_id": tenant_id, "domain": domain, "checks": len(results), "checked_at": now}
+
+
+def _audit_cycle() -> dict:
+ with db() as conn:
+ added = audit_store.sync_domains_from_webhooks(conn)
+ domains = audit_store.list_audit_domains(conn)
+ ran = []
+ for d in domains:
+ ran.append(_run_audit_for_domain(d["tenant_id"], d["domain"]))
+ return {"domains_synced": added, "audits_run": len(ran), "details": ran}
+
+
+class WebhookPayload(BaseModel):
+ event: str
+ domain: str | None = None
+ session_id: str | None = None
+ data: dict | None = None
+
+
+class TicketStatusUpdate(BaseModel):
+ status: str | None = None
+ assigned_to: str | None = None
+
+
+def _parse_payload(raw: str | None) -> dict:
+ if not raw:
+ return {}
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError:
+ return {}
+
+
+def _enrich_ticket(row: sqlite3.Row) -> dict:
+ ticket = dict(row)
+ payload = _parse_payload(ticket.get("payload"))
+ data = payload.get("data") or {}
+ ticket["event"] = payload.get("event")
+ ticket["domain"] = payload.get("domain")
+ ticket["session_id"] = payload.get("session_id")
+ ticket["source"] = payload.get("source") or data.get("source")
+ ticket["email"] = data.get("email")
+ ticket["account_verified"] = data.get("account_verified")
+ ticket["needs_review"] = data.get("needs_review")
+ ticket["dns_mode"] = data.get("dns_mode")
+ ticket["severity"] = data.get("level")
+ ticket["rule_id"] = data.get("rule_id")
+ ticket["description"] = data.get("description")
+ ticket["agent"] = data.get("agent")
+ ticket["billing_state"] = payload.get("billing_state") or data.get("billing_state")
+ ticket["webmail_released"] = payload.get("webmail_released")
+ ticket["company_profile"] = payload.get("company_profile") or data.get("company_profile")
+ ticket["activation_url"] = data.get("activation_url")
+ ticket["desk_message"] = data.get("message")
+ ticket["registration_role"] = data.get("role")
+ ticket["wizard_ticket_id"] = payload.get("wizard_ticket_id") or data.get("wizard_ticket_id")
+ ticket["wizard_client_note"] = payload.get("client_note") or data.get("client_note")
+ ticket["support_source"] = data.get("source")
+ ticket["assist_mode"] = ticket.get("assist_mode")
+ ticket["assisted_by"] = ticket.get("assisted_by")
+ ticket["assisted_at"] = ticket.get("assisted_at")
+ ticket["client_paused"] = bool(ticket.get("client_paused"))
+ ticket["crm_track"] = payload.get("crm_track")
+ ticket["lead_detected_at"] = payload.get("lead_detected_at")
+ ticket["lead_funnel_stage"] = payload.get("lead_funnel_stage")
+ ticket["account_email"] = payload.get("account_email") or data.get("email")
+ if not ticket.get("source"):
+ ticket["source"] = "wazuh" if ticket.get("event") == "wazuh.alert" else "vm112-onboard"
+ ticket["payload"] = payload
+ return ticket
+
+
+def _visible_ticket(ticket: dict, user: auth.DeskUser) -> dict:
+ if should_mask_sensitive(user.role):
+ return auth.mask_ticket(ticket)
+ return ticket
+
+
+def _enrich_event(row: sqlite3.Row) -> dict:
+ ev = dict(row)
+ payload = _parse_payload(ev.get("payload"))
+ data = payload.get("data") or {}
+ ev["payload"] = payload
+ ev["domain"] = payload.get("domain")
+ ev["session_id"] = payload.get("session_id")
+ ev["severity"] = data.get("level")
+ return ev
+
+
+def _funnel_stage_for_event(event_type: str) -> str | None:
+ rank = FUNNEL_EVENT_RANK.get(event_type)
+ if rank is None:
+ return None
+ return FUNNEL_STAGE_BY_RANK.get(rank)
+
+
+def _session_timeline(conn, session_id: str) -> list[dict]:
+ sid = (session_id or "").strip()
+ if not sid:
+ return []
+ rows = conn.execute(
+ """
+ SELECT id, event_type, source, payload, created_at
+ FROM webhook_events
+ WHERE source = ?
+ ORDER BY id ASC
+ LIMIT 500
+ """,
+ (ONBOARD_SOURCE,),
+ ).fetchall()
+ timeline = []
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ if (payload.get("session_id") or "").strip() != sid:
+ continue
+ timeline.append({
+ "id": row["id"],
+ "event_type": row["event_type"],
+ "stage": _funnel_stage_for_event(row["event_type"]),
+ "domain": payload.get("domain"),
+ "data": payload.get("data") or {},
+ "created_at": row["created_at"],
+ })
+ return timeline
+
+
+def _find_ticket_id_by_session(conn, session_id: str) -> int | None:
+ sid = (session_id or "").strip()
+ if not sid:
+ return None
+ row = conn.execute(
+ "SELECT id FROM tickets WHERE session_id = ? ORDER BY id DESC LIMIT 1",
+ (sid,),
+ ).fetchone()
+ if row:
+ return int(row["id"])
+ rows = conn.execute(
+ "SELECT id, payload FROM tickets ORDER BY id DESC LIMIT 300"
+ ).fetchall()
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ if (payload.get("session_id") or "").strip() == sid:
+ return int(row["id"])
+ return None
+
+
+def _find_ticket_id_by_domain(conn, domain: str) -> int | None:
+ dom = (domain or "").strip().lower()
+ if not dom:
+ return None
+ rows = conn.execute(
+ "SELECT id, payload FROM tickets ORDER BY id DESC LIMIT 300"
+ ).fetchall()
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ if (payload.get("domain") or "").strip().lower() == dom:
+ return int(row["id"])
+ return None
+
+
+FUNNEL_BACKFILL_EVENTS = frozenset({
+ "domain.validated",
+ "dns.applied",
+})
+
+
+def _backfill_funnel_notes(conn, session_id: str, ticket_id: int) -> None:
+ """Anexa etapas anteriores ao ticket criado no «Criar servidor»."""
+ sid = (session_id or "").strip()
+ if not sid:
+ return
+ row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
+ if not row:
+ return
+ payload = _parse_payload(row["payload"])
+ notes = list(payload.get("funnel_notes") or [])
+ existing = {n.get("event") for n in notes}
+ rows = conn.execute(
+ """
+ SELECT event_type, payload, created_at
+ FROM webhook_events
+ WHERE source = ?
+ ORDER BY id ASC
+ """,
+ (ONBOARD_SOURCE,),
+ ).fetchall()
+ for ev_row in rows:
+ ev_payload = _parse_payload(ev_row["payload"])
+ if (ev_payload.get("session_id") or "").strip() != sid:
+ continue
+ event_type = ev_row["event_type"]
+ if event_type not in FUNNEL_BACKFILL_EVENTS or event_type in existing:
+ continue
+ notes.append({
+ "event": event_type,
+ "at": ev_row["created_at"],
+ "data": ev_payload.get("data") or {},
+ "backfilled": True,
+ })
+ existing.add(event_type)
+ if notes:
+ payload["funnel_notes"] = notes[-30:]
+ conn.execute(
+ "UPDATE tickets SET payload = ? WHERE id = ?",
+ (json.dumps(payload), ticket_id),
+ )
+
+
+def _attach_funnel_note(
+ conn,
+ session_id: str,
+ event: str,
+ body: WebhookPayload,
+ now: str,
+) -> int | None:
+ tid = _find_ticket_id_by_session(conn, session_id)
+ if not tid and body.domain and not (session_id or "").strip():
+ tid = _find_ticket_id_by_domain(conn, body.domain)
+ if not tid:
+ return None
+ row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (tid,)).fetchone()
+ payload = _parse_payload(row["payload"])
+ notes = list(payload.get("funnel_notes") or [])
+ notes.append({"event": event, "at": now, "data": body.data or {}})
+ payload["funnel_notes"] = notes[-30:]
+ if event == "account.created":
+ email = (body.data or {}).get("email")
+ if email:
+ payload["account_email"] = email
+ domain = body.domain or payload.get("domain") or "sem dominio"
+ conn.execute(
+ "UPDATE tickets SET subject = ? WHERE id = ?",
+ (f"[onboarding] {domain} — {email}", tid),
+ )
+ if event == "onboarding.completed":
+ payload["ready_for_ops"] = True
+ payload["onboarding_outcome"] = "completed"
+ payload["crm_track"] = "onboarding_completed"
+ if event == "company.validated":
+ payload["billing_state"] = (body.data or {}).get("billing_state") or "awaiting_billing_validation"
+ if body.data and body.data.get("company_profile"):
+ payload["company_profile"] = body.data["company_profile"]
+ if event == "webmail.released":
+ payload["webmail_released"] = True
+ payload["webmail_released_at"] = (body.data or {}).get("webmail_released_at")
+ conn.execute(
+ "UPDATE tickets SET payload = ? WHERE id = ?",
+ (json.dumps(payload), tid),
+ )
+ return tid
+
+
+def _funnel_summary(conn, window_hours: int = 48) -> dict:
+ from datetime import timedelta
+
+ cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat()
+ rows = conn.execute(
+ """
+ SELECT event_type, payload, created_at
+ FROM webhook_events
+ WHERE source = ? AND created_at >= ?
+ ORDER BY id ASC
+ """,
+ (ONBOARD_SOURCE, cutoff),
+ ).fetchall()
+
+ sessions: dict[str, dict] = {}
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ sid = (payload.get("session_id") or "").strip()
+ if not sid:
+ continue
+ rank = FUNNEL_EVENT_RANK.get(row["event_type"], 0)
+ sess = sessions.setdefault(
+ sid,
+ {
+ "session_id": sid,
+ "domain": payload.get("domain"),
+ "max_rank": 0,
+ "last_event_at": row["created_at"],
+ "failed": False,
+ },
+ )
+ if payload.get("domain"):
+ sess["domain"] = payload.get("domain")
+ if row["created_at"] >= sess["last_event_at"]:
+ sess["last_event_at"] = row["created_at"]
+ if row["event_type"] == "onboarding.failed":
+ sess["failed"] = True
+ sess["max_rank"] = max(sess["max_rank"], 99)
+ elif rank > sess["max_rank"] and not sess["failed"]:
+ sess["max_rank"] = rank
+
+ stage_counts = {label: 0 for label in FUNNEL_STAGE_BY_RANK.values()}
+ stale_hours = crm_leads.ONBOARD_STALE_HOURS
+ stale_cutoff = (datetime.now(timezone.utc) - timedelta(hours=stale_hours)).isoformat()
+ active_sessions = []
+
+ for sid, sess in sessions.items():
+ if sess["failed"]:
+ stage = "failed"
+ else:
+ stage = FUNNEL_STAGE_BY_RANK.get(sess["max_rank"], "started")
+ stage_counts[stage] = stage_counts.get(stage, 0) + 1
+ ticket_id = _find_ticket_id_by_session(conn, sid)
+ assist = assist_store.get_active_assist(conn, sid)
+ ticket_row = assist_store.find_ticket_by_session(conn, sid)
+ crm_track = None
+ if ticket_row:
+ crm_track = _parse_payload(ticket_row["payload"]).get("crm_track")
+ assist_status = "observing"
+ if assist and assist.get("status") == "active":
+ assist_status = "assisting"
+ elif ticket_row and ticket_row["status"] in ("escalated", "assisting"):
+ assist_status = ticket_row["status"]
+ meta = assist_store.session_funnel_meta(conn, sid, FUNNEL_EVENT_RANK, FUNNEL_STAGE_BY_RANK, ONBOARD_SOURCE)
+ stale = sess["last_event_at"] < stale_cutoff and stage not in ("completed", "failed")
+ active_sessions.append({
+ "session_id": sid,
+ "domain": sess.get("domain"),
+ "current_stage": stage,
+ "last_event_at": sess["last_event_at"],
+ "ticket_id": ticket_id,
+ "stale": stale,
+ "crm_track": crm_track,
+ "is_lead": crm_track == "lead",
+ "assist_status": assist_status,
+ "can_escalate": assist_store.can_intervene(conn, sid, meta),
+ "assisted_by": assist.get("initiated_by_user") if assist else (ticket_row["assigned_to"] if ticket_row else None),
+ })
+
+ active_sessions.sort(key=lambda x: x["last_event_at"], reverse=True)
+ return {
+ "window_hours": window_hours,
+ "stages": stage_counts,
+ "active_sessions": active_sessions[:50],
+ "sessions_total": len(sessions),
+ }
+
+
+def _normalize_wazuh_alert(alert: dict[str, Any]) -> WebhookPayload:
+ rule = alert.get("rule") or {}
+ agent = alert.get("agent") or {}
+ data_field = alert.get("data") if isinstance(alert.get("data"), dict) else {}
+ level = rule.get("level", 0)
+ return WebhookPayload(
+ event="wazuh.alert",
+ domain=agent.get("name") or "unknown-agent",
+ session_id=str(alert.get("id") or alert.get("uuid") or ""),
+ data={
+ "level": level,
+ "rule_id": rule.get("id"),
+ "description": rule.get("description"),
+ "agent": agent.get("name"),
+ "agent_ip": agent.get("ip"),
+ "srcip": data_field.get("srcip"),
+ "source": "wazuh",
+ "raw_rule_groups": rule.get("groups"),
+ },
+ )
+
+
+def _ticket_subject(body: WebhookPayload, source_key: str) -> str:
+ if source_key == "wazuh":
+ data = body.data or {}
+ level = data.get("level", "?")
+ agent = data.get("agent") or body.domain or "agent"
+ desc = (data.get("description") or "alerta")[:80]
+ return f"[wazuh L{level}] {agent} — {desc}"
+ if body.event == "company.validated":
+ domain = body.domain or "sem dominio"
+ profile = (body.data or {}).get("company_profile") or {}
+ legal = (profile.get("legal_name") or domain)[:60]
+ return f"[billing-validation] {domain} — {legal}"
+ domain = body.domain or "sem dominio"
+ email = (body.data or {}).get("email")
+ if body.event in ("onboarding.started", "account.created"):
+ if email:
+ return f"[onboarding] {domain} — {email}"
+ return f"[onboarding] {domain}"
+ if email:
+ return f"[{body.event}] {domain} — {email}"
+ return f"[{body.event}] {domain}"
+
+
+def _should_create_ticket(source_key: str, body: WebhookPayload) -> bool:
+ if body.event not in TICKET_EVENTS_BY_SOURCE.get(source_key, frozenset()):
+ return False
+ if source_key == "wazuh":
+ level = (body.data or {}).get("level") or 0
+ return int(level) >= WAZUH_MIN_TICKET_LEVEL
+ return True
+
+
+def _is_duplicate_event(
+ conn,
+ source_key: str,
+ event: str,
+ session_id: str | None,
+ domain: str | None,
+) -> bool:
+ sid = (session_id or "").strip()
+ dom = (domain or "").strip().lower()
+ if not sid:
+ return False
+ rows = conn.execute(
+ "SELECT payload FROM webhook_events WHERE event_type = ? AND source = ? ORDER BY id DESC LIMIT 300",
+ (event, source_key),
+ ).fetchall()
+ for row in rows:
+ payload = _parse_payload(row["payload"])
+ row_sid = (payload.get("session_id") or "").strip()
+ row_dom = (payload.get("domain") or "").strip().lower()
+ if row_sid == sid and (not dom or row_dom == dom):
+ return True
+ return False
+
+
+def _client_ip_from_request(request: Request | None) -> str | None:
+ if request is None:
+ return None
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ if request.client:
+ return request.client.host
+ return None
+
+
+def _process_ingress(source_key: str, body: WebhookPayload, client_ip: str | None = None) -> dict:
+ now = datetime.now(timezone.utc).isoformat()
+ stored = body.model_dump()
+ stored["source"] = source_key
+ if client_ip:
+ stored["ingress_client_ip"] = client_ip
+ data = stored.get("data")
+ if not isinstance(data, dict):
+ data = {}
+ if not data.get("client_ip"):
+ data["client_ip"] = client_ip
+ stored["data"] = data
+ payload = json.dumps(stored)
+ duplicate = False
+ ticket_created = False
+ ticket_id: int | None = None
+ webhook_event_id: int | None = None
+ tenant_id = TENANT_BY_SOURCE.get(source_key, 1)
+
+ with db() as conn:
+ duplicate = _is_duplicate_event(conn, source_key, body.event, body.session_id, body.domain)
+ if not duplicate:
+ wh_cur = conn.execute(
+ "INSERT INTO webhook_events (event_type,source,payload,created_at) VALUES (?,?,?,?)",
+ (body.event, source_key, payload, now),
+ )
+ webhook_event_id = int(wh_cur.lastrowid)
+ if source_key == "vm112-security":
+ from app import security_store as sec_store
+
+ if body.event in sec_store.AUTO_TICKET_EVENTS:
+ domain_label = body.domain or "sem domínio"
+ subject = f"[security] {domain_label} — {body.event.replace('security.', '')}"
+ session_id = (body.session_id or "").strip() or None
+ cur = conn.execute(
+ """
+ INSERT INTO tickets (tenant_id, subject, status, payload, created_at, session_id)
+ VALUES (?, ?, 'escalated', ?, ?, ?)
+ """,
+ (sec_store.VM112_TENANT_ID, subject, payload, now, session_id),
+ )
+ ticket_created = True
+ ticket_id = int(cur.lastrowid)
+ elif _should_create_ticket(source_key, body):
+ session_id = (body.session_id or "").strip() or None
+ initial_status = "escalated" if body.event == "onboarding.failed" else "open"
+ ticket_payload = _parse_payload(payload)
+ if body.event == "onboarding.started":
+ ticket_payload["crm_track"] = "onboarding"
+ ticket_payload["funnel_notes"] = []
+ cur = conn.execute(
+ """
+ INSERT INTO tickets
+ (tenant_id,subject,status,payload,created_at,session_id,assigned_to,assigned_at)
+ VALUES (?,?,?,?,?,?,NULL,NULL)
+ """,
+ (
+ tenant_id,
+ _ticket_subject(body, source_key),
+ initial_status,
+ json.dumps(ticket_payload),
+ now,
+ session_id,
+ ),
+ )
+ ticket_created = True
+ ticket_id = int(cur.lastrowid)
+ if body.event == "onboarding.started" and session_id:
+ _backfill_funnel_notes(conn, session_id, ticket_id)
+ if body.event == "onboarding.failed" and session_id:
+ process_escalation_webhook(conn, body, now)
+ elif body.event in ASSIST_ESCALATION_EVENTS and (body.session_id or "").strip():
+ ticket_id = process_escalation_webhook(conn, body, now).get("ticket_id")
+ elif body.event == "onboarding.assist.started" and (body.session_id or "").strip():
+ from app.assist_routes import process_assist_started
+
+ ticket_id = process_assist_started(conn, body, now).get("ticket_id")
+ elif body.event == "onboarding.assist.ended" and (body.session_id or "").strip():
+ from app.assist_routes import process_assist_ended
+
+ ticket_id = process_assist_ended(conn, body, now).get("ticket_id")
+ elif (
+ source_key == ONBOARD_SOURCE
+ and body.event in FUNNEL_NOTE_EVENTS
+ and ((body.session_id or "").strip() or (body.domain or "").strip())
+ ):
+ ticket_id = _attach_funnel_note(conn, body.session_id or "", body.event, body, now)
+ if not ticket_id and body.event in ("company.validated", "account.created"):
+ session_id = (body.session_id or "").strip() or None
+ fallback_payload = _parse_payload(payload)
+ if body.event == "account.created":
+ fallback_payload["crm_track"] = "onboarding"
+ fallback_payload["funnel_notes"] = []
+ cur = conn.execute(
+ """
+ INSERT INTO tickets
+ (tenant_id,subject,status,payload,created_at,session_id,assigned_to,assigned_at)
+ VALUES (?,?,?,?,?,?,NULL,NULL)
+ """,
+ (
+ tenant_id,
+ _ticket_subject(body, source_key),
+ "open",
+ json.dumps(fallback_payload),
+ now,
+ session_id,
+ ),
+ )
+ ticket_created = True
+ ticket_id = int(cur.lastrowid)
+ if body.event == "company.validated":
+ enriched = _parse_payload(payload)
+ enriched["billing_state"] = "awaiting_billing_validation"
+ conn.execute(
+ "UPDATE tickets SET payload = ? WHERE id = ?",
+ (json.dumps(enriched), ticket_id),
+ )
+ if source_key == ONBOARD_SOURCE:
+ from app import carbonio_release_store
+
+ tid = ticket_id
+ if not tid and (body.session_id or "").strip():
+ tid = _find_ticket_id_by_session(conn, body.session_id or "")
+ from app import billing_store
+ if body.event == "company.validated" and body.domain:
+ billing_store.upsert_from_company_validated(
+ conn,
+ domain=body.domain,
+ session_id=body.session_id,
+ ticket_id=tid,
+ data=body.data,
+ )
+ carbonio_release_store.upsert_from_webhook(
+ conn,
+ event=body.event,
+ domain=body.domain,
+ session_id=body.session_id,
+ data=body.data,
+ webhook_event_id=webhook_event_id,
+ ticket_id=tid,
+ )
+ conn.commit()
+ elif source_key == ONBOARD_SOURCE and (body.session_id or "").strip():
+ ticket_id = _find_ticket_id_by_session(conn, body.session_id or "")
+
+ if not duplicate:
+ redis.from_url(REDIS_URL).lpush("ops:events", f"{source_key}:{body.event}")
+ if source_key == ONBOARD_SOURCE:
+ detail = (body.data or {}).get("email") or body.domain or body.session_id or ""
+ try:
+ push_service.notify_ops_event(body.event, domain=body.domain, detail=str(detail))
+ except Exception:
+ pass
+
+ return {
+ "accepted": True,
+ "status": "accepted",
+ "event": body.event,
+ "source": source_key,
+ "duplicate": duplicate,
+ "ticket_created": ticket_created,
+ "ticket_id": ticket_id,
+ }
+
+
+def _verify_secret(integration: str, provided: str | None) -> None:
+ expected = INTEGRATION_SECRETS.get(integration)
+ if not expected or provided != expected:
+ raise HTTPException(401, "invalid webhook secret")
+
+
+@app.on_event("startup")
+def startup():
+ init_db()
+ try:
+ with db() as conn:
+ audit_store.sync_domains_from_webhooks(conn)
+ except Exception:
+ pass
+
+
+@app.get("/health")
+@app.get("/api/health")
+def health():
+ redis.from_url(REDIS_URL).ping()
+ return {"status": "ok", "service": "ligbox-ops-api", "version": "0.9.7-spec029-agentic"}
+
+
+@app.get("/api/v1/integrations")
+def list_integrations(user: auth.DeskUser = Depends(auth.get_current_user)):
+ return {
+ "integrations": [
+ {"id": "onboard", "source": "vm112-onboard", "tenant_id": 1, "description": "Portal onboarding VM112"},
+ {"id": "wazuh", "source": "wazuh", "tenant_id": 2, "description": "Wazuh SOC VM104", "min_ticket_level": WAZUH_MIN_TICKET_LEVEL},
+ ]
+ }
+
+
+@app.get("/api/v1/integrations/health")
+def integrations_health(user: auth.DeskUser = Depends(auth.require_internal_or_user)):
+ with db() as conn:
+ return integration_health.build_health_report(conn)
+
+
+@app.post("/api/v1/integrations/onboard/test")
+def test_onboard_webhook(user: auth.DeskUser = Depends(auth.get_current_user)):
+ if user.role not in ("super_admin", "admin"):
+ raise HTTPException(403, "insufficient permissions")
+ session_id = f"desk-test-{int(datetime.now(timezone.utc).timestamp())}"
+ body = WebhookPayload(
+ event="integration.test",
+ domain="ops-healthcheck.ligbox",
+ session_id=session_id,
+ data={"triggered_by": user.username, "test": True},
+ )
+ result = _process_ingress(ONBOARD_SOURCE, body)
+ result["domain"] = body.domain
+ result["session_id"] = session_id
+ result["tested_at"] = datetime.now(timezone.utc).isoformat()
+ result["triggered_by"] = user.username
+ result["message"] = (
+ "Webhook processado com sucesso. O evento aparece no feed SOC e em Eventos."
+ if not result.get("duplicate")
+ else "Evento duplicado — o pipe está OK, mas este teste já existia na janela de deduplicação."
+ )
+ return result
+
+
+@app.get("/api/v1/tenants")
+def list_tenants(user: auth.DeskUser = Depends(auth.get_current_user)):
+ with db() as conn:
+ rows = conn.execute("SELECT id,name,ip,role,created_at FROM tenants ORDER BY id").fetchall()
+ return {"tenants": [dict(r) for r in rows]}
+
+
+@app.get("/api/v1/desk/tickets")
+def list_tickets(
+ status: str | None = Query(default=None),
+ source: str | None = Query(default=None),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_read_tickets(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ query = f"SELECT {TICKET_COLUMNS} FROM tickets"
+ params: list[Any] = []
+ clauses = []
+ if status == "active":
+ clauses.append(f"status IN ({','.join('?' * len(TICKET_ACTIVE_STATUSES))})")
+ params.extend(sorted(TICKET_ACTIVE_STATUSES))
+ elif status in TICKET_ACTIVE_STATUSES or status == "closed":
+ clauses.append("status = ?")
+ params.append(status)
+ if clauses:
+ query += " WHERE " + " AND ".join(clauses)
+ query += " ORDER BY id DESC LIMIT 100"
+ rows = conn.execute(query, params).fetchall()
+ tickets = [_visible_ticket(_enrich_ticket(r), user) for r in rows]
+ if source:
+ tickets = [
+ t for t in tickets
+ if t.get("source") == source
+ or (t.get("payload") or {}).get("source") == source
+ ]
+ return {"tickets": tickets}
+
+
+@app.get("/api/v1/desk/tickets/{ticket_id}")
+def get_ticket(ticket_id: int, user: auth.DeskUser = Depends(auth.get_current_user)):
+ if not can_read_tickets(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ row = conn.execute(
+ f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?",
+ (ticket_id,),
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "ticket not found")
+ ticket = _enrich_ticket(row)
+ sid = ticket.get("session_id")
+ if sid:
+ timeline = _session_timeline(conn, sid)
+ from app.funnel_timing import apply_module_timing
+
+ enriched, timing_meta = apply_module_timing(timeline)
+ ticket["timeline"] = enriched
+ ticket["related_events"] = enriched[-20:]
+ if timing_meta:
+ ticket["timing"] = timing_meta
+ else:
+ ticket["timeline"] = []
+ ticket["related_events"] = []
+ ticket["ready_for_ops"] = (ticket.get("payload") or {}).get("ready_for_ops", False)
+ return _visible_ticket(ticket, user)
+
+
+@app.patch("/api/v1/desk/tickets/{ticket_id}")
+def update_ticket(
+ ticket_id: int,
+ body: TicketStatusUpdate,
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if body.status is None and body.assigned_to is None:
+ raise HTTPException(400, "status or assigned_to required")
+ if body.status is not None and body.status not in ("open", "closed", "escalated", "assisting", "resolved"):
+ raise HTTPException(400, "status inválido")
+ now = datetime.now(timezone.utc).isoformat()
+ with db() as conn:
+ row = conn.execute(
+ f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?",
+ (ticket_id,),
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "ticket not found")
+ ticket = _enrich_ticket(row)
+ if body.status is not None and not can_patch_ticket(user.role, ticket, user.username):
+ raise HTTPException(403, "insufficient permissions")
+ if body.assigned_to is not None and not can_assign_ticket(user.role, body.assigned_to, user.username):
+ raise HTTPException(403, "insufficient permissions")
+ if body.status is not None:
+ conn.execute("UPDATE tickets SET status = ? WHERE id = ?", (body.status, ticket_id))
+ if body.assigned_to is not None:
+ assignee = body.assigned_to.strip().lower() if body.assigned_to else None
+ if assignee == "root":
+ assignee = "root"
+ conn.execute(
+ "UPDATE tickets SET assigned_to = ?, assigned_at = ? WHERE id = ?",
+ (assignee, now if assignee else None, ticket_id),
+ )
+ conn.commit()
+ row = conn.execute(
+ f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?",
+ (ticket_id,),
+ ).fetchone()
+ return {"ticket": _visible_ticket(_enrich_ticket(row), user)}
+
+
+@app.get("/api/v1/desk/summary")
+def desk_summary(user: auth.DeskUser = Depends(auth.get_current_user)):
+ with db() as conn:
+ open_count = conn.execute(
+ f"SELECT COUNT(*) c FROM tickets WHERE status IN ({','.join('?' * len(TICKET_ACTIVE_STATUSES))})",
+ tuple(sorted(TICKET_ACTIVE_STATUSES)),
+ ).fetchone()["c"]
+ escalated_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'escalated'").fetchone()["c"]
+ assisting_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'assisting'").fetchone()["c"]
+ closed_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'closed'").fetchone()["c"]
+ event_count = conn.execute("SELECT COUNT(*) c FROM webhook_events").fetchone()["c"]
+ wazuh_events = conn.execute("SELECT COUNT(*) c FROM webhook_events WHERE source = 'wazuh'").fetchone()["c"]
+ tenant_count = conn.execute("SELECT COUNT(*) c FROM tenants").fetchone()["c"]
+ recent = conn.execute(
+ f"SELECT {TICKET_COLUMNS} FROM tickets ORDER BY id DESC LIMIT 5"
+ ).fetchall()
+ leads_count = crm_leads.count_leads(conn)
+ summary = {
+ "tickets_open": open_count,
+ "tickets_escalated": escalated_count,
+ "tickets_assisting": assisting_count,
+ "tickets_closed": closed_count,
+ "tickets_total": open_count + closed_count,
+ "leads_abandoned": leads_count,
+ "onboard_stale_hours": crm_leads.ONBOARD_STALE_HOURS,
+ "live_window_minutes": live_presence.DEFAULT_WINDOW_MINUTES,
+ "support_live_hours": live_presence.SUPPORT_LIVE_HOURS,
+ "webhook_events": event_count,
+ "wazuh_events": wazuh_events,
+ "tenants": tenant_count,
+ "recent_tickets": [_enrich_ticket(r) for r in recent],
+ }
+ from app import billing_store
+ with db() as conn:
+ bs = billing_store.summary(conn)
+ summary.update({
+ "billing_pending": bs["billing_pending"],
+ "billing_active": bs["billing_active"],
+ "billing_total": bs["billing_total"],
+ })
+ if should_mask_sensitive(user.role):
+ return auth.mask_summary_for_noc(summary)
+ return summary
+
+
+@app.get("/api/v1/webhooks/events")
+def list_webhook_events(
+ session_id: str | None = Query(default=None),
+ source: str | None = Query(default=None),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if user.role == "noc" and not source:
+ source = "wazuh"
+ if not can_list_webhook_events(user.role, source):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ if source:
+ rows = conn.execute(
+ "SELECT id,event_type,source,payload,created_at FROM webhook_events WHERE source = ? ORDER BY id DESC LIMIT 100",
+ (source,),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT id,event_type,source,payload,created_at FROM webhook_events ORDER BY id DESC LIMIT 100"
+ ).fetchall()
+ if session_id:
+ sid = session_id.strip()
+ rows = [
+ r for r in rows
+ if (_parse_payload(r["payload"]).get("session_id") or "").strip() == sid
+ ]
+ return {"events": [_enrich_event(r) for r in rows[:50]]}
+
+
+@app.get("/api/v1/onboard/funnel")
+def onboard_funnel(
+ window_hours: int = Query(default=48, ge=1, le=168),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_read_funnel(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ data = _funnel_summary(conn, window_hours=window_hours)
+ if should_mask_sensitive(user.role):
+ data["active_sessions"] = [
+ {
+ "session_id": (s.get("session_id") or "")[:8] + "…",
+ "domain": s.get("domain"),
+ "current_stage": s.get("current_stage"),
+ "last_event_at": s.get("last_event_at"),
+ "ticket_id": s.get("ticket_id"),
+ "stale": s.get("stale"),
+ }
+ for s in data.get("active_sessions", [])
+ ]
+ return data
+
+
+@app.get("/api/v1/live/presence")
+def live_presence_api(
+ window_minutes: int = Query(default=live_presence.DEFAULT_WINDOW_MINUTES, ge=5, le=720),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_read_tickets(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ sessions = live_presence.build_presence_sessions(
+ conn,
+ onboard_source=ONBOARD_SOURCE,
+ window_minutes=window_minutes,
+ find_ticket_by_session=assist_store.find_ticket_by_session,
+ )
+ return {
+ "sessions": sessions,
+ "source": "desk-funnel-fallback",
+ "window_minutes": window_minutes,
+ "support_live_hours": live_presence.SUPPORT_LIVE_HOURS,
+ }
+
+
+@app.get("/api/v1/onboard/sessions/{session_id}/timeline")
+def onboard_session_timeline(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
+ if not can_read_session_timeline(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ sid = session_id.strip()
+ if not sid:
+ raise HTTPException(400, "session_id required")
+ with db() as conn:
+ timeline = _session_timeline(conn, sid)
+ domain = timeline[-1]["domain"] if timeline else None
+ if not domain:
+ for row in timeline:
+ if row.get("domain"):
+ domain = row["domain"]
+ break
+ ticket_id = _find_ticket_id_by_session(conn, sid)
+ result = {
+ "session_id": sid,
+ "domain": domain,
+ "ticket_id": ticket_id,
+ "events": timeline,
+ }
+ from app.modules import store as module_store
+ from app.funnel_timing import build_timing_report
+
+ from app.funnel_timing import apply_module_timing
+
+ if module_store.is_module_enabled("funnel-timing") and timeline:
+ enriched, timing_meta = apply_module_timing(timeline)
+ result["events"] = enriched
+ if timing_meta:
+ result["timing"] = timing_meta
+ return result
+
+
+@app.get("/api/v1/audit/overview")
+def audit_overview(user: auth.DeskUser = Depends(auth.get_current_user)):
+ if not can_read_audit_overview(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ return audit_store.build_overview(conn)
+
+
+@app.get("/api/v1/audit/tenants/{tenant_id}/details")
+def audit_tenant_details(
+ tenant_id: int,
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_read_audit_overview(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ with db() as conn:
+ details = audit_store.tenant_details(conn, tenant_id)
+ if not details:
+ raise HTTPException(404, "tenant not found")
+ return details
+
+
+@app.get("/api/v1/dns/cloudflare/records")
+async def cloudflare_dns_records(
+ domain: str = Query(..., min_length=3),
+ email_service: bool | None = Query(default=None),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_read_cloudflare_dns(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ domain = domain.lower().strip()
+ return await fetch_domain_dns(domain, email_service=email_service)
+
+
+@app.get("/api/v1/audit/tenants/{tenant_id}/scorecard")
+def audit_scorecard(
+ tenant_id: int,
+ domain: str = Query(...),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_read_audit_scorecard(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ domain = domain.lower().strip()
+ if not domain:
+ raise HTTPException(400, "domain query param required")
+ with db() as conn:
+ row = conn.execute("SELECT id FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "tenant not found")
+ return audit_store.scorecard(conn, tenant_id, domain)
+
+
+@app.post("/api/v1/audit/run/{tenant_id}")
+def audit_run(
+ tenant_id: int,
+ domain: str = Query(...),
+ user: auth.DeskUser = Depends(auth.get_current_user),
+):
+ if not can_run_audit(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ domain = domain.lower().strip()
+ if not domain:
+ raise HTTPException(400, "domain query param required")
+ with db() as conn:
+ row = conn.execute("SELECT id FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "tenant not found")
+ conn.execute(
+ """
+ INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
+ VALUES (?, ?, 'manual', ?)
+ """,
+ (tenant_id, domain, datetime.now(timezone.utc).isoformat()),
+ )
+ conn.commit()
+ result = _run_audit_for_domain(tenant_id, domain)
+ return {"status": "completed", **result}
+
+
+@app.post("/api/v1/audit/cycle")
+def audit_cycle(user: auth.DeskUser = Depends(auth.require_internal_or_user)):
+ if user.username not in ("worker", "system") and not can_run_audit(user.role):
+ raise HTTPException(403, "insufficient permissions")
+ return _audit_cycle()
+
+
+@app.post("/api/v1/webhooks/ingress/{integration}")
+async def webhook_ingress(
+ integration: str,
+ request: Request,
+ x_webhook_secret: str | None = Header(default=None),
+):
+ if integration not in INTEGRATION_SOURCES:
+ raise HTTPException(404, f"unknown integration: {integration}")
+ _verify_secret(integration, x_webhook_secret)
+ source_key = INTEGRATION_SOURCES[integration]
+ raw = await request.json()
+
+ if integration == "wazuh" and isinstance(raw, dict) and "rule" in raw:
+ body = _normalize_wazuh_alert(raw)
+ else:
+ body = WebhookPayload.model_validate(raw)
+
+ return _process_ingress(source_key, body, _client_ip_from_request(request))
+
+
+@app.post("/api/v1/webhooks/onboard")
+def webhook_onboard(
+ body: WebhookPayload,
+ request: Request,
+ x_webhook_secret: str | None = Header(default=None),
+):
+ _verify_secret("onboard", x_webhook_secret)
+ return _process_ingress("vm112-onboard", body, _client_ip_from_request(request))
+
+
+@app.post("/api/v1/webhooks/security")
+def webhook_security(
+ body: WebhookPayload,
+ request: Request,
+ x_webhook_secret: str | None = Header(default=None),
+):
+ _verify_secret("security", x_webhook_secret)
+ if not body.event.startswith("security."):
+ raise HTTPException(400, "event must start with security.")
+ return _process_ingress("vm112-security", body, _client_ip_from_request(request))
+
+
+@app.get("/api/v1/infra/vm112/status")
+def vm112_status(user: auth.DeskUser = Depends(auth.get_current_user)):
+ try:
+ with httpx.Client(timeout=8.0) as c:
+ r = c.get(f"{VM112_API}/api/onboarding/health")
+ return {"vm112": r.json(), "http_status": r.status_code}
+ except Exception as e:
+ return {"vm112": None, "error": str(e)}
+
+
+@app.get("/api/v1/infra/wazuh/status")
+def wazuh_status(user: auth.DeskUser = Depends(auth.get_current_user)):
+ try:
+ with httpx.Client(timeout=8.0, verify=False) as c:
+ r = c.get("https://10.10.10.104:55000/")
+ online = r.status_code in (200, 401)
+ body = r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text[:200]
+ return {
+ "wazuh_api": body,
+ "http_status": r.status_code,
+ "api_online": online,
+ }
+ except Exception as e:
+ return {"wazuh_api": None, "http_status": None, "api_online": False, "error": str(e)}
diff --git a/ligbox-ops-platform/deploy/vm122-desk/frontend/assets/app.js b/ligbox-ops-platform/deploy/vm122-desk/frontend/assets/app.js
new file mode 100644
index 0000000..95a87ba
--- /dev/null
+++ b/ligbox-ops-platform/deploy/vm122-desk/frontend/assets/app.js
@@ -0,0 +1,4062 @@
+const API = '/api';
+
+async function api(path, options = {}, timeoutMs) {
+ const res = await fetchWithTimeout(`${API}${path}`, {
+ headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
+ ...options,
+ }, timeoutMs);
+ 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();
+}
+
+/** Requisições longas (OpenPanel provision) — sem AbortController. */
+async function apiLongRunning(path, options = {}) {
+ const res = await fetch(`${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, '>')
+ .replace(/"/g, '"');
+}
+
+function sessionHashHtml(sessionId, { full = true } = {}) {
+ const id = (sessionId || '').trim();
+ if (!id) return '';
+ const shown = full ? id : `${id.slice(0, 8)}…${id.slice(-4)}`;
+ return `${esc(shown)}`;
+}
+
+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,
+ openPanelTestRunning: false,
+};
+
+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 normalizeAssistStatus(status) {
+ if (status === 'active') return 'assisting';
+ return status;
+}
+
+function assistStatusLabel(status) {
+ return {
+ observing: 'observando',
+ escalated: 'escalado',
+ assisting: 'assistindo',
+ handed_off: 'devolvido ao cliente',
+ ended: 'assistência encerrada',
+ }[status] || status || 'observando';
+}
+
+function assistBadge(status) {
+ if (!status || status === 'observing') {
+ return 'observando ';
+ }
+ const cls = status === 'assisting' ? 'assisting'
+ : status === 'escalated' ? 'escalated'
+ : status === 'handed_off' || status === 'ended' ? 'resolved'
+ : status === 'closed' ? 'closed'
+ : 'open';
+ return `${esc(assistStatusLabel(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'
+ ? 'concluído '
+ : outcome === 'failed'
+ ? 'falhou '
+ : '';
+ const label = latest ? (SOC_EVENT_LABELS[latest] || latest) : '—';
+ const sev = latest && typeof socEventSeverity === 'function' ? socEventSeverity(latest) : 'open';
+ return `
+ Estado funil ${esc(label)} ${outcomeBadge}
+ ${showOpened ? `Abertura ticket ${esc(opened)} ` : ''}`;
+}
+
+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 = `
+ ${esc(user.display_name || user.username)}
+ ${esc(user.username)} · ${esc(label)} `;
+ }
+ if (header) {
+ header.hidden = false;
+ header.innerHTML = `${esc(user.display_name || user.username)} ${esc(label)} `;
+ }
+ 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);
+ if (state.openPanelTestRunning) return;
+ 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 = ' API online';
+ return h;
+ } catch {
+ el.className = 'status-pill err';
+ el.innerHTML = ' API offline';
+ return null;
+ }
+}
+
+async function renderDashboard() {
+ const box = document.getElementById('dashboard-content');
+ box.innerHTML = 'Carregando…
';
+ 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 `
+
+
+
+ ${esc(s.domain || '—')}
+
+ ${esc(FUNNEL_LABELS[s.current_stage] || s.current_stage)} · onboarding VM112
+
+ ${sessionHashHtml(s.session_id)}
+ ${s.ticket_id ? `#${s.ticket_id} ` : ''}
+
+
+ ${assistBadge(status)}
+ ${s.is_lead || s.crm_track === 'lead' ? 'lead ' : ''}
+ ${['company_validated','webmail_released','completed'].includes(s.current_stage) ? '💳 billing ' : ''}
+ ${s.stale && s.crm_track !== 'lead' && !s.is_lead ? 'abandonado ' : ''}
+
+ `;
+ }).join('');
+ box.innerHTML = `
+
+
+ ${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' }) : ''}
+
+ ${dashboardPulseHtml({ audit, vm112, wazuh, vmOk, wazuhOk })}
+
+
+
+
Funil 48h
+ ${funnelBarHtml(funnel.stages || {}, funnel.sessions_total || 0)}
+
+
+
+
Sessões ativas
+
+ Assistindo
+ Observando
+ ${sessions.length} total
+
+
+ ${sessionCards
+ ? `
${sessionCards}
`
+ : '
Sem sessões recentes
'}
+
+ ${canReadLeads() ? `
+
+
+
Leads abandonados
+ Ver todos
+
+ ${(leadsData.leads || []).slice(0, 6).map(leadRowHtml).join('') || '
Nenhum lead — sessões stale viram lead após ${summary.onboard_stale_hours ?? 24}h
'}
+
` : ''}
+
+
Tickets recentes
+
+ ${(summary.recent_tickets || []).map(ticketRowHtml).join('') || '
Sem tickets
'}
+
+
+
+ ${canAssist() && (techRanking.ranking || []).length ? `
+
+
+
Ranking técnicos
+ 30d · assumidos / movimento
+
+ ${techRankingHtml(techRanking.ranking)}
+
` : ''}`;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+function sourceBadge(src) {
+ if (src === 'desk-registration') return 'desk ';
+ if (src === 'wazuh') return 'wazuh ';
+ if (src === 'vm112-onboard') return 'onboard ';
+ return src ? `${esc(src)} ` : '';
+}
+
+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 `L${n} `;
+}
+
+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 `
+
+
${FUNNEL_LABELS[key] || key}
+
+
${n}
+
`;
+ })
+ .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
+ ? 'concluído '
+ : `em curso `;
+ 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
+ ? `Σ ${esc(timing.total_duration_label)} `
+ : `Σ ${esc(ev.duration_from_start_label || '—')}`;
+ return `
+
+ ${esc(eventTypeLabel(ev))}
+ ${fmtDate(ev.created_at || ev.at)}
+ ${idx > 0 ? `+${esc(prev)} ` : '—'}
+ ${total}
+ `;
+ }).join('');
+ return `
+
+
+
+
Relógio por fase
+
Duração entre etapas do onboarding VM112
+
+ ${statusBadge}
+
+
+
+ Tempo total
+ ${esc(timing.total_duration_label)}
+
+
+ Início
+ ${fmtDate(timing.started_at)}
+
+ ${timing.is_completed ? `
+
+ Concluído
+ ${fmtDate(timing.completed_at)}
+
` : `
+
+ Parado há
+ ${esc(timing.idle_since_label || '—')}
+
`}
+
+
+
+ Fase Registado Δ fase Acumulado
+ ${rows}
+
+
+
`;
+}
+
+function timingSummaryHtml(timing) {
+ if (!timing || !window.DeskModules?.isEnabled('funnel-timing')) return '';
+ const idle = timing.is_completed ? '' : `Parado há ${esc(timing.idle_since_label)} `;
+ return `
+
+ Total ${esc(timing.total_duration_label)}
+ ${idle}
+ ${timing.completed_at ? `Concluído ${fmtDate(timing.completed_at)} ` : ''}
+
`;
+}
+
+function timelineHtml(events, timingMeta, opts = {}) {
+ if (!events?.length) return '';
+ const showTiming = !opts.compact && window.DeskModules?.isEnabled('funnel-timing');
+ return `${!opts.compact ? timingSummaryHtml(timingMeta) : ''}${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 !== '—'
+ ? `+${esc(e.duration_from_prev_label)} `
+ : '';
+ const fromStart = showTiming && e.duration_from_start_label
+ ? `Σ ${esc(e.duration_from_start_label)} `
+ : '';
+ return `
+
+
+
+
${esc(evt)}
+ ${e.stage ? `
${esc(e.stage)} ` : ''}
+ ${prevDur}${fromStart}
+
${fmtDate(at)}
+
+ `;
+ }
+ )
+ .join('')} `;
+}
+
+function healthBadge(status) {
+ const map = { healthy: 'ok', degraded: 'review', critical: 'closed', unknown: 'open' };
+ const cls = map[status] || 'open';
+ return `${esc(status || 'unknown')} `;
+}
+
+function checkStatusBadge(status) {
+ const cls = { pass: 'ok', warn: 'review', fail: 'closed', error: 'closed', skip: 'open' }[status] || 'open';
+ return `${esc(status)} `;
+}
+
+function leadRowHtml(l) {
+ return `
+
+
+ ${esc(l.domain || '—')}
+ lead
+
+ ${esc(l.email || 'sem e-mail')} · ${esc(FUNNEL_LABELS[l.funnel_stage] || l.funnel_stage || '—')}
+ #${l.ticket_id} · parado ${fmtDate(l.last_event_at)}
+ `;
+}
+
+function billingTicketIcon(t) {
+ if ((t.subject || '').includes('[billing-validation]') || t.billing_state) return ' 💳';
+ return '';
+}
+
+function ticketRowHtml(t) {
+ const review = t.needs_review ? 'revisão ' : '';
+ const verified = t.account_verified ? 'verificado ' : '';
+ const lead = t.crm_track === 'lead' ? 'lead ' : '';
+ 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 `
+
+ ${esc(statusLabel(t.status))}
+
+ ${lead}${sourceBadge(t.source)}${severityBadge(t.severity)}${review}${verified}
+ `;
+}
+
+function assistActionsHtml(sessionId, meta, consoleExtra = {}) {
+ if (!canAssist() || !sessionId) return '';
+ const canAct = meta?.can_escalate;
+ const assistStatus = normalizeAssistStatus(meta?.assist_status);
+ const ticketStatus = meta?.ticket_status;
+ const isAssisting = assistStatus === 'assisting' || ticketStatus === 'assisting';
+ const isEscalated = assistStatus === 'escalated' || ticketStatus === 'escalated';
+ const status = assistStatus || ticketStatus;
+ const deskActions = (consoleExtra.actions || []).map((a) =>
+ `${esc(a.label)} `
+ ).join('');
+ const links = (consoleExtra.links || []).map((l) =>
+ `${esc(l.label)} `
+ ).join('');
+ const audit = (meta?.actions || []).slice(-8).map((a) =>
+ `${esc(a.action)} · ${esc(a.actor)} · ${fmtDate(a.created_at)} `
+ ).join('');
+ return `
+
+
Console de assistência
+
${assistBadge(status)}${meta?.assisted_by ? ` · ${esc(meta.assisted_by)}` : ''}
+
+ ${!isAssisting && !isEscalated && canAct ? `Escalar ` : ''}
+ ${canAct && !isAssisting ? `Assumir sessão ` : ''}
+ ${isAssisting ? `Devolver ao cliente ` : ''}
+ ${!canAct ? 'Intervenção disponível após domínio validado ' : ''}
+
+ ${deskActions ? `
Acções Desk ${deskActions}
` : ''}
+ ${links ? `
` : ''}
+ ${audit ? `
Movimento / audit ` : ''}
+
`;
+}
+
+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' || action === 'resume-wizard') && 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 `
+
+
+
+ ${value}
+ ${esc(label)}
+
+
`;
+}
+
+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 `
+
+
+
+ ${esc(t.name)}
+ ${t.score?.pass ?? 0}/${t.score?.total ?? 8} checks
+
+ ${healthBadge(t.status)}
+
`;
+ }).join('');
+ return `
+
+ ${auditChips}
+
+
+
+ VM112 Portal
+ ${esc(vm112.vm112?.service || vm112.error || '—')}
+
+
${vmOk ? 'online' : 'check'}
+
+
+
+
+ VM104 Wazuh
+ API ${wazuh.http_status ?? '—'}
+
+
${wazuhOk ? 'online' : 'check'}
+
+
`;
+}
+
+function techRankingHtml(ranking) {
+ if (!ranking?.length) return 'Sem movimento no período
';
+ return `
+
+ # Técnico Assumidos Escalados Acções Score
+
+ ${ranking.slice(0, 8).map((r, i) => `
+
+ ${i + 1}
+ ${esc(r.username)}
+ ${r.assumidos}
+ ${r.escalados}
+ ${r.acoes}
+ ${r.score}
+ `).join('')}
+
+
`;
+}
+
+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 = `Carregando detalhes de ${esc(domain)} …
`;
+
+ 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 'Dados DNS indisponíveis.
';
+ }
+ if (dns.error && !dns.records?.length) {
+ return `
+ ${esc(dns.error)}
+ ${dns.email_service ? 'Serviço: servidor de e-mail (onboarding)
' : ''}`;
+ }
+ const rows = (dns.records || []).map((r) => `
+
+ ${esc(dnsPurposeLabel(r.purpose))}
+ ${esc(r.name)}
+ ${esc(r.type)}
+ ${esc(r.content)}
+ `).join('');
+ const summary = dns.summary || {};
+ const zone = dns.zone || {};
+ return `
+
+
+ ${summary.total || 0}
+ registos na zona
+
+
+ ${summary.email_related || 0}
+ para e-mail
+
+
${dns.email_service ? 'E-mail' : 'DNS'}
+
+ Zona ${esc(zone.name || '—')}${dns.error ? ` · ${esc(dns.error)}` : ''}
+
+
+ Função Nome Tipo Conteúdo
+ ${rows || 'Sem registos para este domínio. '}
+
+
`;
+}
+
+function htmlCloudflareDnsCard(dns) {
+ if (!dns) {
+ return `
+
+
Apontamentos DNS (Cloudflare)
+
Dados DNS indisponíveis.
+
`;
+ }
+ if (dns.error && !dns.records?.length) {
+ return `
+
+
Apontamentos DNS (Cloudflare)
+
${esc(dns.error)}
+ ${dns.email_service ? '
Serviço: servidor de e-mail (onboarding)
' : ''}
+
`;
+ }
+ const rows = (dns.records || []).map((r) => `
+
+ ${esc(dnsPurposeLabel(r.purpose))}
+ ${esc(r.name)}
+ ${esc(r.type)} ${r.priority != null ? ` prio ${r.priority} ` : ''}
+ ${esc(r.content)}
+ ${r.proxied ? 'proxy' : 'DNS only'} · TTL ${r.ttl ?? '—'}
+ `).join('');
+ const summary = dns.summary || {};
+ const zone = dns.zone || {};
+ return `
+
+
+
Apontamentos DNS (Cloudflare)
+ ${dns.email_service ? 'Servidor de e-mail' : 'DNS geral'}
+
+
+ Zona ${esc(zone.name || '—')} · ${summary.total || 0} registo(s)
+ · ${summary.email_related || 0} para e-mail
+ ${dns.error ? ` · ${esc(dns.error)} ` : ''}
+
+
+
+
+ Função Nome Tipo Conteúdo Estado
+
+ ${rows || 'Sem registos DNS para este domínio na zona Cloudflare. '}
+
+
+
`;
+}
+
+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 `${esc(label)} `;
+}
+
+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) => `${esc(i.domain)} · ${esc(i.check_id)} — ${esc(i.message || i.status)} `)
+ .join('');
+ const apiLabel = t.api_online ? `API online (${t.http_status || '—'})` : 'API offline';
+ return `
+
+
+
+
${esc(t.name)}
+
${esc(t.ip)} · ${t.alerts_total || 0} alerta(s) · ${t.agents_count || 0} agente(s)
+
+ ${healthBadge(t.status)}
+
+
+ ${t.alerts_high || 0} alto (L≥${t.min_ticket_level || 10})
+ ${t.open_tickets || 0} ticket(s) aberto(s)
+ ${esc(apiLabel)}
+
+ Último alerta: ${fmtDate(t.last_alert_at)}
+ ${issues ? `` : 'Sem alertas Wazuh registados — integração ativa aguarda eventos.
'}
+ Clique para ver agentes, alertas por nível e tickets SOC
+ `;
+}
+
+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) => `
+
+ ${esc(a.agent)}
+ ${esc(a.agent_ip || '—')}
+ ${a.alerts_count}
+ L${a.max_level}
+ ${relativeTimeAgo(a.last_seen)}
+ `).join('');
+
+ const alertRows = (data.alerts || []).slice(0, 40).map((a) => `
+
+ ${severityBadge(a.level)}
+ ${esc(a.agent)}
+ ${esc(a.description || '—')}
+ ${esc(a.srcip || '—')}
+ ${esc(a.agent_ip || '—')}
+ ${relativeTimeAgo(a.created_at)}
+ `).join('');
+
+ const ticketRows = (data.tickets || []).slice(0, 15).map((t) => `
+
+ ${esc(statusLabel(t.status))}
+
+
${esc(t.subject)}
+
#${t.id} · ${fmtDate(t.created_at)}
+
+ `).join('');
+
+ body.innerHTML = `
+
+
${s.alerts_total || 0} Alertas
+
${s.alerts_24h || 0} 24h
+
${s.agents_total || 0} Agentes
+
${s.level_10_plus || 0} L≥${data.min_ticket_level || 10}
+
${s.open_tickets || 0} Tickets
+
+
+ Monitorização de segurança VM104 — webhooks wazuh.alert com nível ≥ ${data.min_ticket_level || 10} geram ticket na VM122.
+
+
+
+
Agentes monitorados
+ ${agentRows ? `
+
+
+ Agente IP Alertas Máx Último
+ ${agentRows}
+
+
` : '
Nenhum agente com alertas registados.
'}
+
+
+
Feed de alertas
+ ${alertRows ? `
+
+
+ Nível Agente Descrição Src IP Agent IP Hora
+ ${alertRows}
+
+
` : '
Sem alertas.
'}
+
+
+ ${ticketRows ? `
+
+
Tickets Wazuh
+
${ticketRows}
+
` : ''}`;
+
+ 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 `${esc(label)} `;
+}
+
+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 `${esc(label)} `;
+}
+
+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 = ` `;
+ offset += len;
+ return el;
+ }).join('');
+ return `${arcs}${total} `;
+}
+
+function wizardSecVBarSvg(items, width = 260, height = 120) {
+ if (!items.length) return 'Sem dados
';
+ 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 `
+
+ ${esc(item.short || item.label)}
+ ${item.value} `;
+ }).join('');
+ return `${bars} `;
+}
+
+function wizardSecHBarHtml(items) {
+ if (!items.length) return 'Sem dados
';
+ const max = Math.max(...items.map((i) => i.value), 1);
+ return items.map((item) => `
+
+
${esc(item.label)}
+
+
${item.value}
+
`).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) =>
+ `${esc((ev.client_ip || '—'))} · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : 'sem sessão'} `
+ ).join('');
+ return `
+
+
+
+
Acesso Usuário — Cybersecurity
+
Portal público · browser · handoff · não é o wizard VM112
+
+ ${healthBadge(status)}
+
+ ${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ão(ões) · janela 24h
+ ${issues ? `` : 'Sem alertas de acesso nas últimas 24h
'}
+ Clique para ver dashboard de ameaças, guia técnico e relatório
+ `;
+}
+
+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 `
+
+ ${esc(wizardSecurityEventLabel(ev.event_type))}
+ ${wizardSecRiskCell(score)}
+ ${ev.session_id ? sessionHashHtml(ev.session_id, { full: false }) : '—'}
+ ${esc(ev.client_ip || '—')}
+ ${fmtDate(ev.created_at)}
+ `;
+ }).join('');
+
+ const accessStatus = wizardSecAccessStatus(s);
+ const issueLines = recent.slice(0, 3).map((ev) =>
+ `${esc(ev.client_ip || '—')} · ${esc(wizardSecurityEventLabel(ev.event_type))} — ${ev.session_id ? `${esc(ev.session_id.slice(0, 14))}…` : 'sem sessão'} `
+ ).join('');
+
+ const dashboardGrid = `
+
+
+
+ ${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}
+
+
+
+
+ ${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 },
+ ])}
+
+ Alto (${severityCounts.high})
+ Elevado (${severityCounts.warn})
+ Baixo (${severityCounts.info})
+
+
+
+
+
+ ${wizardSecVBarSvg(vectorBars)}
+
+
+
+
+ ${topIps.length ? topIps.map(([ip, n], i) => `
+
+ ${i + 1}
+ ${esc(ip)}
+ ${n} evt
+
`).join('') : '
Nenhum IP registado
'}
+
+
+
+
+ ${wizardSecHBarHtml(riskBars)}
+
+
+
+
+
+ Ameaça Nível Sessão IP Hora
+ ${threatRows || 'Sem ameaças nas últimas 24h '}
+
+
+
+
`;
+
+ return `
+
+
+
Área independente
+
Acesso de usuário — Cibersegurança
+
Eventos gerados quando alguém acede ao portal público , preenche formulários ou faz login (handoff). Isto é segurança de acesso — não mede DNS, Carbonio, certificados nem progresso do wizard VM112.
+
+
+
+
+
+
Threat tracking — portal & sessões
+
Browser · CSP · inputs · handoff · Spec 021
+
+ ${healthBadge(accessStatus)}
+
+
${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
+
Janela ${s.window_hours || 24}h · origem vm112-security
+ ${issueLines ? `
` : '
Nenhum incidente de acesso nas últimas 24h
'}
+
${dashboardGrid}
+
+
+
+
+
+
+
+
Este painel cobre apenas o comportamento de quem acede ao sistema — visitantes, clientes no portal e tentativas de abuso em formulários públicos.
+
+ CSP (browser) — scripts ou recursos bloqueados no navegador do usuário
+ Input audit — padrões SQL/XSS em campos enviados pelo usuário
+ Handoff — token de login expirado, reutilizado ou inválido
+ Auth / sessão — falhas de autenticação ou anomalias de sessão
+
+
${standalone
+ ? 'Domínios, DNS e Carbonio estão no card Ligbox Datacenter — Node VM001 — área separada.'
+ : '≠ Saúde do wizard VM112 (domínios, e-mail, certificados) — ver secção Onboard abaixo.'}
+
+
+
+ Como proceder — guia técnico
+
+
+ Input bloqueado / CSP — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.
+ Handoff rejeitado — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).
+ Correlacionar — Tickets → Onboard → hash da sessão. Compare com funil.
+ Takeover — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.
+ Falso positivo — Domínios com caracteres especiais podem gerar input_warn .
+ Escalação — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.
+
+
+
+
+
+ ${standalone ? '' : `
+
+ Ligbox Datacenter — Node VM001 — wizard, domínios & infraestrutura
+
`}`;
+}
+
+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) =>
+ `${esc(i.check_id)} — ${esc(i.message || i.status)} `
+ ).join('');
+ return `
+
+
+
${esc(d.domain)}
+
+ ${executionStatusBadge(d.execution_status)}
+ ${healthBadge(d.audit_status)}
+
+
+
+ ${esc(d.email || 'sem e-mail')}
+ ${esc(d.funnel_stage_label || d.funnel_stage || '—')}
+ início ${fmtDate(d.started_at)}
+ último ${fmtDate(d.last_event_at)}
+ ${d.timing && window.DeskModules?.isEnabled('funnel-timing') ? `total ${esc(d.timing.total_duration_label)} ` : ''}
+ IP ${esc(d.client_ip || '—')}
+ ${d.ticket_id ? `ticket #${d.ticket_id} ` : ''}
+
+ ${issuePreview ? `` : ''}
+ `;
+ }).join('');
+ body.innerHTML = `
+
+
+
Ligbox Datacenter — Node VM001
+
Saúde do wizard, domínios em onboarding, DNS, certificados e Carbonio
+ ${window.DeskModules?.isEnabled('wizard-security') ? '
← Acesso Usuário — Cybersecurity ' : ''}
+
+
+
${s.domains_total || 0} Total
+
${s.in_progress || 0} Em execução
+
${s.completed || 0} Concluídos
+
${s.failed || 0} Falharam
+
${s.with_issues || 0} Com erros
+
+ Clique num domínio para ver apontamentos DNS Cloudflare, timeline, checks e IP de acesso.
+ ${rows || 'Nenhum domínio auditado neste tenant.
'}
+ `;
+ 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 = 'Carregando detalhes…
';
+ 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) => `
+
+ ${esc(c.label || c.check_id)}
+ ${checkStatusBadge(c.status)}
+ ${esc(c.message || '—')}
+ ${fmtDate(c.checked_at)}
+ `).join('');
+ const timelineBlock = d.timeline?.length
+ ? `${phaseTimingCardHtml(d.timing, d.timeline)}Eventos ${timelineHtml(d.timeline, d.timing, { compact: true })}`
+ : 'Sem eventos webhook para este domínio.
';
+ const ips = (d.client_ips || []).filter(Boolean);
+ body.innerHTML = `
+
+ ← Voltar à lista
+ ${esc(data.name)}
+
+
+
+
${esc(d.domain)}
+
${esc(d.email || 'sem e-mail')} · sessão ${esc((d.session_id || '—').slice(0, 18))}
+
+
+ ${executionStatusBadge(d.execution_status)}
+ ${healthBadge(d.audit_status)}
+
+
+
+
Etapa funil ${esc(d.funnel_stage_label || d.funnel_stage || '—')}
+
Início ${fmtDate(d.started_at)}
+
Último evento ${esc(d.last_event || '—')} · ${fmtDate(d.last_event_at)}
+
Último audit ${fmtDate(d.last_audit_at)}
+
IP de acesso ${esc(d.client_ip || (ips[0] || '—'))}
+
Ticket ${d.ticket_id ? `#${d.ticket_id} (${esc(d.ticket_status || '—')})` : '—'}
+
+ ${ips.length > 1 ? `IPs observados: ${ips.map((ip) => `${esc(ip)}`).join(' · ')}
` : ''}
+ ${htmlCloudflareDnsCard(dnsData)}
+
+
Checks de auditoria
+
+
+ Check Status Mensagem Verificado
+ ${checkRows || 'Sem checks '}
+
+
+
+
+
Timeline de execução
+ ${timelineBlock}
+
+ ${d.ticket_id ? `Abrir ticket #${d.ticket_id}
` : ''}`;
+ 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 = 'Carregando detalhes…
';
+ 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 = `
+ Erro: ${esc(e.message)}
+ Tentar novamente `;
+ 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 = 'Carregando segurança de acesso…
';
+ 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 = `
+ Erro ao carregar segurança de acesso: ${esc(e.message)}
+ Verifique ligação ao Desk e permissões de audit.
+ Tentar novamente `;
+ body.querySelector('[data-retry-user-access]')?.addEventListener('click', () => openUserAccessModal());
+ }
+}
+
+async function renderOverview() {
+ const el = document.getElementById('overview-content');
+ el.innerHTML = 'Carregando overview…
';
+ 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) => `${esc(i.domain)} · ${esc(i.check_id)} — ${esc(i.message || i.status)} `)
+ .join('');
+ cards.push(`
+
+
+
+
${esc(t.name)}
+
${esc(t.ip)} · ${t.domains_count || 0} domínio(s) · wizard & infra
+
+ ${healthBadge(t.status)}
+
+ ${t.score?.pass ?? 0}/${t.score?.total ?? 8} pass · ${t.score?.warn ?? 0} warn · ${t.score?.fail ?? 0} fail
+ Último audit: ${fmtDate(t.last_audit_at)}
+ ${issues ? `` : 'Sem issues ou aguarde o 1º ciclo de auditoria
'}
+ Clique para ver domínios, DNS, checks e timeline do onboarding
+ `);
+ });
+ el.innerHTML = cards.length
+ ? `${cards.join('')}
`
+ : 'Nenhum tenant auditado. Complete onboarding ou POST /audit/cycle.
';
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+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 ` `;
+}
+
+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 = 'Módulo Serviços não carregado.
';
+}
+
+async function renderLeads() {
+ const el = document.getElementById('leads-content');
+ if (!canReadLeads()) {
+ el.innerHTML = 'Sem permissão para ver leads
';
+ return;
+ }
+ el.innerHTML = 'Carregando leads…
';
+ try {
+ const data = await api('/v1/crm/leads');
+ const leads = data.leads || [];
+ el.innerHTML = `
+
+
+
Leads abandonados
+ Stale ≥ ${data.stale_hours ?? 24}h sem concluir onboarding
+
+
+ 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).
+
+ ${leads.length
+ ? `
${leads.map(leadRowHtml).join('')}
`
+ : '
Nenhum lead no momento
'}
+
`;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+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 = 'Carregando tickets…
';
+ 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('')
+ : 'Nenhum ticket neste filtro
';
+ 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 = 'Selecione um ticket ou sessão do funil
';
+ }
+ } catch (e) {
+ listEl.innerHTML = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+async function renderSessionDetail() {
+ const detailEl = document.getElementById('ticket-detail');
+ const sessionId = state.selectedSessionId;
+ if (!sessionId) return;
+ detailEl.innerHTML = '';
+ try {
+ const meta = await loadAssistMeta(sessionId);
+ detailEl.innerHTML = `
+
+
Sessão onboarding
+
+ Domínio ${esc(meta.domain || '—')}
+ Etapa ${esc(FUNNEL_LABELS[meta.funnel_stage] || meta.funnel_stage || '—')}
+ Sessão ${esc(meta.session_id)}
+ ${meta.ticket_id ? `Ticket #${meta.ticket_id} ` : ''}
+
+ ${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)}
Eventos ${timelineHtml(meta.timeline, meta.timing, { compact: true })}` : ''}
+
`;
+ bindAssistActions(detailEl, sessionId);
+ bindLiveTimingClock(detailEl);
+ } catch (e) {
+ detailEl.innerHTML = ``;
+ }
+}
+
+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 = '';
+ 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 = `
+
+
+
Ticket #${t.id}
+ ${esc(statusLabel(t.status))}
+
+
+ Origem ${sourceBadge(t.source)}
+ Domínio/Agente ${esc(t.domain || t.agent || '—')}
+ Email ${esc(t.email || '—')}
+ Evento ${esc(t.event || '—')}
+ ${t.assigned_to ? `Atribuído ${esc(t.assigned_to)} ` : ''}
+ ${t.assisted_by ? `Assistido por ${esc(t.assisted_by)} ` : ''}
+ ${t.client_paused ? 'Cliente pausado ' : ''}
+ ${t.ready_for_ops ? 'Ops ready for ops ' : ''}
+ ${t.severity != null ? `Severidade ${severityBadge(t.severity)} ` : ''}
+ ${t.rule_id ? `Regra ${esc(t.rule_id)} ` : ''}
+ ${t.description ? `Descrição ${esc(t.description)} ` : ''}
+ ${t.desk_message ? `Nota ${esc(t.desk_message)} ` : ''}
+ ${t.registration_role ? `Perfil ${esc(roleLabel(t.registration_role))} ` : ''}
+ ${t.ativation_url ? `Ativar conta Abrir link de ativação ` : ''}
+ Sessão/Alert ID ${esc(t.session_id || '—')}
+ Verificado ${t.account_verified ? 'Sim' : 'Não'}
+ Revisão ${t.needs_review ? 'Necessária' : 'Não'}
+ Criado ${fmtDate(t.created_at)}
+
+ ${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) : ''}
+
+ ${canPatchTickets() ? (closeStatuses.includes(t.status)
+ ? `Fechar ticket `
+ : `Reabrir ticket `) : ''}
+
+ ${timeline.length ? `${phaseTimingCardHtml(timing, timeline)}
Eventos ${timelineHtml(timeline, timing, { compact: true })}` : ''}
+
Payload
+
${esc(JSON.stringify(t.payload, null, 2))}
+
`;
+ 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 = ``;
+ }
+}
+
+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 = 'Carregando eventos…
';
+ 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 `
+ ${e.id}
+ ${sourceBadge(e.source)}
+ ${esc(e.event_type)} ${severityBadge(dataObj.level || e.severity)}
+ ${esc(p.domain || '—')}
+ ${esc((p.session_id || '').slice(0, 16))}
+ ${fmtDate(e.created_at)}
+ `;
+ }).join('');
+ el.innerHTML = `
+
+
+ ID Origem Evento Agente/Domínio Ref Data
+ ${rows || 'Sem eventos '}
+
+
`;
+ } catch (e) {
+ el.innerHTML = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+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 `${esc(label)} `;
+}
+
+function carbonioReleaseGuideHtml() {
+ return `
+
+ Guia — libertar e-mail ACCOUNT_EXISTS
+
+ O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).
+ Confirme o e-mail exacto e a sua senha Desk (não a do Carbonio nem root).
+ A ação remove apenas a conta Carbonio (zmprov da) — domínio, DNS e portal mantêm-se.
+ Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.
+ Dois técnicos a resolver em paralelo: só o primeiro consegue; o outro vê «já resolvido».
+
+ `;
+}
+
+function carbonioResolveFormHtml(block) {
+ if (block.status === 'resolved') {
+ return `Resolvido por ${esc(block.resolved_by)} em ${fmtDate(block.resolved_at)}${block.resolution_note ? ` — ${esc(block.resolution_note)}` : ''}
`;
+ }
+ if (!canReadTickets()) return '';
+ return `
+ `;
+}
+
+function carbonioBlockPanelHtml(block) {
+ return `
+
+
+
Bloqueio Carbonio — ACCOUNT_EXISTS
+ ${carbonioBlockStatusBadge(block.status)}
+
+
+ E-mail ${esc(block.email)} · domínio ${esc(block.domain)}
+ ${block.ticket_id ? ` · bloqueio #${block.id}` : ''}
+
+ ${block.error_message ? `
${esc(block.error_message.slice(0, 240))}
` : ''}
+ ${carbonioReleaseGuideHtml()}
+ ${carbonioResolveFormHtml(block)}
+
`;
+}
+
+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 = 'Módulo Bloqueios Carbonio desativado.
';
+ return;
+ }
+ el.innerHTML = 'Carregando bloqueios Carbonio…
';
+ 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('')
+ : 'Nenhum bloqueio pendente — novos casos aparecem aqui via webhook onboarding.failed + ACCOUNT_EXISTS.
';
+ const resolvedRows = resolvedBlocks.map((b) => `
+
+ #${b.id}
+ ${esc(b.email)}
+ ${esc(b.domain)}
+ ${esc(b.resolved_by || '—')}
+ ${fmtDate(b.resolved_at)}
+ ${b.ticket_id ? `#${b.ticket_id}` : '—'}
+ `).join('');
+ el.innerHTML = `
+
+
+ ${pending.total || pendingBlocks.length} pendente(s) ·
+ ${resolved.total || resolvedBlocks.length} resolvido(s) recentes
+
+
+ ${pendingCards}
+
+ Histórico resolvido (${resolvedBlocks.length})
+
+
+ ID E-mail Domínio Resolvido por Quando Ticket
+ ${resolvedRows || 'Nenhum '}
+
+
+ `;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+async function renderSecurityEvents() {
+ syncEventsToolbar();
+ const el = document.getElementById('events-content');
+ if (!window.DeskModules?.isEnabled('wizard-security')) {
+ el.innerHTML = 'Módulo Segurança Wizard desativado.
';
+ return;
+ }
+ el.innerHTML = 'Carregando eventos de segurança…
';
+ 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) => `
+
+ ${wizardSecuritySeverityBadge(ev.severity)}
+ ${esc(wizardSecurityEventLabel(ev.event_type))}
+ ${ev.session_id ? sessionHashHtml(ev.session_id) : '—'}
+ ${esc(ev.domain || '—')}
+ ${esc(ev.client_ip || '—')}
+ ${esc(ev.endpoint || ev.reason || '—')}
+ ${fmtDate(ev.created_at)}
+ `).join('');
+ el.innerHTML = `
+
+
+ Últimas 24h: ${summary.csp_violations || 0} CSP ·
+ ${summary.inputs_blocked || 0} bloqueados ·
+ ${summary.handoffs_rejected || 0} handoffs rejeitados
+
+
Guia rápido para técnicos
+
+ Input bloqueado → anote hash + IP; se repetido, escale.
+ Handoff rejeitado → cliente deve refazer login; ticket escalado automático.
+ Clique na linha para abrir a sessão em Tickets.
+
+
+
+
+ Nível Evento Sessão Domínio IP Detalhe Quando
+ ${rows || 'Nenhum evento de segurança '}
+
+
+
`;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+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 `${esc(label)} `;
+}
+
+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 `${(steps || []).map((step) => {
+ const status = step.status || 'pending';
+ return `
+
+ ${esc(fmtDate(step.at))}
+
+ ${esc(step.label)}
+ ${step.detail ? `${esc(step.detail)} ` : ''}
+
+ `;
+ }).join('')} `;
+}
+
+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 = 'Carregando…
';
+ 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 ${esc(job.id)}`;
+ 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]) => `
+ ${esc(label)} ${Number(desk[key] || 0)} `).join('');
+ const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : [];
+ const timeline = (job.timeline || []).length ? job.timeline : vm112Steps;
+ body.innerHTML = `
+
+
+
Removido no Desk (VM122)
+
+ ${deskRows}
+ Total ${Object.values(desk).reduce((a, b) => a + Number(b || 0), 0)}
+
+
+
+
Removido na VM112
+
${vm112RemovedSummary(vm112)}
+ ${job.elapsed_vm112 ? `
Duração VM112: ${job.elapsed_vm112}s
` : ''}
+ ${job.error ? `
${esc(job.error)}
` : ''}
+
+
+
+
Timeline completa
+ ${timeline.length ? renderPurgeTimelineHtml(timeline) : '
Sem passos registados
'}
+
`;
+ })
+ .catch((e) => {
+ body.innerHTML = `Erro: ${esc(e.message)}
`;
+ });
+ document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
+ el.onclick = closePurgeHistoryModal;
+ });
+}
+
+async function renderPurgeHistory() {
+ syncEventsToolbar();
+ const el = document.getElementById('events-content');
+ el.innerHTML = 'Carregando histórico de purges…
';
+ try {
+ const data = await api('/v1/vm112/purge/jobs?limit=200');
+ const rows = (data.jobs || []).map((j) => `
+
+ ${esc(j.id)}
+ ${esc(j.domain)}
+ ${purgeStatusBadge(j.status)}
+ ${esc(j.by || '—')}
+ ${esc(deskRemovedSummary(j.desk))}
+ ${fmtDate(j.created_at)}
+ ${j.elapsed_vm112 ? `${j.elapsed_vm112}s` : '—'}
+ `).join('');
+ el.innerHTML = `
+
+
Clique numa linha para ver a timeline completa e o que foi removido em cada serviço.
+
+
+
+ Job Domínio Status Usuário
+ Desk removido Quando VM112
+
+
+ ${rows || 'Nenhum purge registado '}
+
+ ${data.total > (data.jobs || []).length ? `
A mostrar ${(data.jobs || []).length} de ${data.total} purges.
` : ''}
+
`;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+async function renderTenants() {
+ const el = document.getElementById('tenants-content');
+ el.innerHTML = 'Carregando…
';
+ try {
+ const data = await api('/v1/tenants');
+ el.innerHTML = `
+
+
+ ID Nome IP Papel Desde
+ ${(data.tenants || []).map((t) => `
+
+ ${t.id}
+ ${esc(t.name)}
+ ${esc(t.ip)}
+ ${esc(t.role)}
+ ${fmtDate(t.created_at)}
+ `).join('')}
+
+
+
`;
+ } catch (e) {
+ el.innerHTML = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+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 `há ${mins} min`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `há ${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days === 1) return 'ontem';
+ if (days < 7) return `há ${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 `${esc(roleLabel(role))} `;
+}
+
+function mfaBadgeHtml(user) {
+ if (user.totp_enabled) {
+ const backups = Number(user.backup_codes_remaining || 0);
+ const hint = backups > 0 ? ` · ${backups} backup` : '';
+ return `2FA${hint} `;
+ }
+ return 'sem 2FA ';
+}
+
+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) => `${esc(r.label)} `)
+ .join('');
+ return `${opts} `;
+ }).join('');
+}
+
+function roleSelectHtml(username, current, assignableOnly = true) {
+ const options = assignableOnly && current !== 'super_admin'
+ ? ASSIGNABLE_ROLE_OPTIONS
+ : ROLE_OPTIONS;
+ const opts = options.map((r) =>
+ `${r.label} `
+ ).join('');
+ return `${opts} `;
+}
+
+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 = `
+
+
${esc(userInitials(user.display_name, user.username))}
+
+
${esc(user.display_name || user.username)}
+
${esc(email)}
+
${esc(user.username)}
+
+
+
+ Criado ${fmtDate(user.created_at)}
+ Último login ${fmtRelative(user.last_login_at)}
+ Segurança ${mfaBadgeHtml(user)}
+
+ `;
+
+ 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 = 'Sem permissão
';
+ return;
+ }
+ el.innerHTML = 'Carregando equipe…
';
+ 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) => `
+
+
+
+
${esc(userInitials(u.display_name, u.username))}
+
+ ${esc(u.display_name || u.username)}
+ ${esc(u.email || u.username)}
+
+
+
+ ${roleBadgeHtml(u.role)}
+ ${mfaBadgeHtml(u)}
+ ${fmtRelative(u.last_login_at)}
+ ${u.active ? 'ativo ' : 'inativo '}
+
+ Editar
+
+ `).join('');
+
+ el.innerHTML = `
+
+
+
+
+
${users.length} membros
+
${activeCount} ativos
+
${mfaCount} com 2FA
+
${inactiveCount} inativos
+
+
+
+
+ Buscar
+
+
+ Perfil
+
+ Todos
+ ${ROLE_OPTIONS.map((r) => `${r.label} `).join('')}
+
+
+ Estado
+
+ Todos
+ Ativos
+ Inativos
+
+
+ 2FA
+
+ Todos
+ Com 2FA
+ Sem 2FA
+
+
+
+
+
+
+
+
+ Membro
+ Perfil
+ Segurança
+ Último login
+ Estado
+
+
+
+
+ ${rows || 'Nenhum membro encontrado '}
+
+
+
+
+
`;
+
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+async function renderModules() {
+ const el = document.getElementById('modules-content');
+ if (!el) return;
+ const user = getUser();
+ if (user?.role !== 'super_admin') {
+ el.innerHTML = 'Apenas Super Admin pode gerenciar módulos.
';
+ return;
+ }
+ el.innerHTML = 'Carregando módulos…
';
+ try {
+ await DeskModules.load();
+ const mods = DeskModules.list;
+ el.innerHTML = `
+
+
Módulos do Desk
+
Desativar um módulo remove-o do menu e desliga enriquecimentos na API — o núcleo continua estável.
+
+ ${mods.map((m) => `
+
+
+ ${esc(m.label)}
+ ${esc(m.description)}
+ ${esc(m.id)}
+ ${m.locked ? 'núcleo ' : ''}
+
+
+ `).join('')}
+
+
`;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+const REG_ROLE_LABELS = ROLE_LABELS;
+
+async function renderMessages() {
+ const el = document.getElementById('messages-content');
+ if (!canManageUsers()) {
+ el.innerHTML = 'Sem permissão
';
+ return;
+ }
+ el.innerHTML = 'Carregando pedidos…
';
+ 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) => `
+
+
+
+
${esc(r.email)}
+
${esc(r.display_name || '—')} · ${fmtDate(r.created_at)}
+
+
pendente
+
+
Perfil a atribuir
+
+ ${registrationRoleSelectHtml('technician')}
+
+
+
+ Aprovar
+ Rejeitar
+
+
+
`).join('');
+ const historyRows = history.map((r) => `
+
+ ${esc(r.email)}
+ ${esc(statusLabel(r.status))}
+ ${esc(r.role ? roleLabel(r.role) : '—')}
+ ${fmtDate(r.updated_at || r.created_at)}
+ `).join('');
+ el.innerHTML = `
+
+
Pedidos pendentes (${pending.length})
+ ${pendingCards || '
Nenhum pedido pendente
'}
+ ${history.length ? `
+
Histórico
+
+
+ E-mail Estado Perfil Atualizado
+ ${historyRows}
+
+
` : ''}
+
`;
+ 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 = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+async function renderAccount(force = false) {
+ const el = document.getElementById('account-content');
+ if (state.accountLoaded && !force) {
+ return;
+ }
+ const saved = force ? null : readAccountPwdForm();
+ el.innerHTML = 'Carregando…
';
+ try {
+ const me = await api('/v1/auth/me');
+ const totpOn = Boolean(me.totp_enabled || me.mfa_enabled);
+ el.innerHTML = `
+
+
Minha conta
+
+ E-mail / login ${esc(me.email || me.username)}
+ Perfil ${esc(roleLabel(me.role))}
+ Nome ${esc(me.display_name || '—')}
+ Último login ${fmtDate(me.last_login_at)}
+ 2FA (app) ${totpOn ? 'ativo ' : 'não configurado '}
+ ${totpOn ? `Códigos backup ${Number(me.backup_codes_remaining || 0)} restante(s) ` : ''}
+
+
+
Alterar senha
+
+ ${totpOn
+ ? 'Por segurança, confirme a senha atual e o código do autenticador (sessão aberta). Se perdeu o app, use recuperação no login ou um código de backup.'
+ : 'Informe a senha atual e escolha uma nova (mín. 8 caracteres).'}
+
+
+
`;
+ restoreAccountPwdForm(saved);
+ bindAccountPwdForm(totpOn);
+ state.accountLoaded = true;
+ } catch (e) {
+ el.innerHTML = `Erro: ${esc(e.message)}
`;
+ 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 `
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+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 `
+
+
${esc(FUNNEL_LABELS[key] || key)}
+
+
${n}
+
`;
+ }).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 = `
+
+
+
+ ${esc(result.message || (ok ? 'Integração VM112 → VM122 respondendo corretamente.' : 'Falha ao processar webhook.'))}
+
+
+ Status ${esc(result.status || '—')}
+ Evento ${esc(result.event || '—')}
+ Origem ${esc(result.source || '—')}
+ Domínio ${esc(result.domain || '—')}
+ Sessão ${esc(result.session_id || '—')}
+ Duplicado ${dup ? 'sim' : 'não'}
+ Ticket criado ${result.ticket_created ? `sim (#${result.ticket_id})` : 'não'}
+ Disparado por ${esc(result.triggered_by || '—')}
+
+
+ Este teste simula um evento integration.test no endpoint
+ POST /api/v1/webhooks/onboard — 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.
+
+
+ Ver em Eventos
+ Fechar
+
+
`;
+
+ 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 = `
+
+
+
+ ${esc(msg)}
+
+ ${is403 ? `
Apenas perfis super_admin e admin podem executar o teste de webhook.
` : ''}
+
Verifique se a API está online, se a sessão não expirou e se o usuário tem permissão.
+
+ Fechar
+
+
`;
+ 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) => `
+
+ ${esc(s.name)} — ${esc(s.detail || (s.ok ? 'OK' : 'FAIL'))}
+ `).join('');
+
+ body.innerHTML = `
+
+
+
+ ${esc(result.message || (ok ? 'Multidomínio OK' : 'Falha'))}
+
+
+
+ Suite openpanel-multidomain-api-confirm — usa os nomes que indicou nos campos
+ (ou gera automaticamente). Aguarde até 3 minutos sem sair da página.
+ Script CLI: scripts/test-openpanel-multidomain-api.sh
+
+
+ Fechar
+
+
`;
+ 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 = `
+
+
+
+ ${esc(msg)}
+
+ ${is403 ? '
Perfis: super_admin , devops , developer .
' : ''}
+
+ Fechar
+
+
`;
+ 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;
+ const user1 = (document.getElementById('op-test-user1')?.value || '').trim().toLowerCase();
+ const domain1 = (document.getElementById('op-test-domain1')?.value || '').trim().toLowerCase();
+ const user2 = (document.getElementById('op-test-user2')?.value || '').trim().toLowerCase();
+ const domain2 = (document.getElementById('op-test-domain2')?.value || '').trim().toLowerCase();
+ const password = (document.getElementById('op-test-password')?.value || 'LbOpenTest805353').trim();
+ const cleanup = document.getElementById('op-test-cleanup')?.checked !== false;
+ const autoNames = document.getElementById('op-test-auto-names')?.checked !== false;
+
+ const accounts = [];
+ if (user1 || domain1) accounts.push({ username: user1, domain: domain1 });
+ if (user2 || domain2) accounts.push({ username: user2, domain: domain2 });
+
+ state.openPanelTestRunning = true;
+ if (pollTimer) clearInterval(pollTimer);
+
+ if (btn) {
+ btn.disabled = true;
+ btn.textContent = 'Simulando… aguarde (~2 min)';
+ }
+ try {
+ const r = await apiLongRunning('/v1/vm123/openpanel/test-confirm', {
+ method: 'POST',
+ body: JSON.stringify({
+ accounts,
+ password,
+ cleanup,
+ auto_names: autoNames && accounts.length === 0,
+ check_reference: true,
+ }),
+ });
+ showOpenPanelTestResult(r);
+ } catch (ex) {
+ const msg = ex?.name === 'AbortError' || /aborted/i.test(ex?.message || '')
+ ? 'Requisição interrompida — aguarde até 3 minutos sem mudar de página.'
+ : (ex?.message || String(ex));
+ showOpenPanelTestError({ message: msg });
+ } finally {
+ state.openPanelTestRunning = false;
+ reschedulePoll();
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = prevLabel || 'Executar simulação';
+ }
+ }
+}
+
+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 = 'Módulo Infra 2 SOC desativado. Active em Módulos .
';
+ return;
+ }
+ el.innerHTML = 'Carregando SOC…
';
+ 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 = `
+
+
+
+
+
+ Integração
+ ${esc(intStatus)}
+ VM112 onboard
+
+
+ Gap webhook
+ ${gapMin != null ? `${gapMin}m` : '—'}
+ limite ${health.webhook_gap_alert_minutes || 15} min
+
+
+ Eventos
+ ${windowEvents.length}
+ ~${eventsPerHour}/h · ${state.socWindow}
+
+
+ Sessões
+ ${funnel.sessions_total || sessions.length}
+ funil ativo
+
+
+ Tickets onboard
+ ${onboardTicketsOpen}
+ abertos agora
+
+
+ Alertas
+ ${alerts.length}
+ ${lastWh.event ? esc(lastWh.event) : 'sem eventos'}
+
+ ${window.DeskModules?.isEnabled('wizard-security') ? `
+
+ Segurança wizard
+ ${secSummary.total || 0}
+ CSP ${secSummary.csp_violations || 0} · bloq ${secSummary.inputs_blocked || 0}
+
` : ''}
+
+
+
+
+
+ VM112 Wizard
+
+
webhook POST /onboard →
+
+
+ VM122 Desk
+
+
←
+
+
+ VM104 Wazuh
+
+
alertas level ≥10
+
+
+
+
+
+
Feed ao vivo — VM112 + Segurança
+ ${feedEvents.length} recentes
+
+
+ ${feedEvents.length ? `
+
+ Evento Domínio Hora
+
+ ${feedEvents.map((ev, i) => {
+ const p = ev.payload || {};
+ const sev = socEventSeverity(ev.event_type);
+ const isNew = flashNew && i === 0;
+ return `
+
+
+ ${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)}
+ ${esc(p.domain || ev.domain || '—')}
+ ${relativeTimeAgo(ev.created_at)}
+ `;
+ }).join('')}
+
+
` : '
Nenhum evento VM112 registrado
'}
+
+
+
+
+
+
Volume & funil
+ ${state.socWindow}
+
+
+
+
+ Eventos VM112
+ máx ${Math.max(...chartBuckets, 0)}
+
+ ${socAreaChartSvg(chartBuckets)}
+
+
+ ${socPipelineHtml(funnel.stages || {}, funnel.sessions_total || 0)}
+
+
+
+
+
+
+
Sessões VM112
+ ${sessions.length} ativas
+
+
+
+ ${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
+ ? `
Σ ${esc(tmeta.total_duration_label)} `
+ : '';
+ const idleHint = tmeta && !tmeta.is_completed
+ ? ` · parado ${esc(tmeta.idle_since_label)}`
+ : '';
+ return `
+
+ ${esc(initials)}
+
+ ${esc(s.domain || 'sem domínio')}
+ ${esc(FUNNEL_LABELS[stage] || stage)} · onboarding · ${relativeTimeAgo(s.last_event_at)}${idleHint}
+ ${s.session_id ? `${sessionHashHtml(s.session_id)} ` : ''}
+
+ ${timingBadge}
+ ${s.ticket_id ? `#${s.ticket_id} ` : '— '}
+ `;
+ }).join('') : '
Sem sessões no período
'}
+
+
+
+
+
+
+
+
Alertas SOC
+
+
+ ${alerts.length ? alerts.map((a) => `
+
+
+ ${esc(a.message)}
+ `).join('') : `
+
+
+ Integração saudável — sem alertas ativos
+ `}
+ ${lastWh.domain ? `
+
+
+ Último: ${esc(lastWh.event)} · ${esc(lastWh.domain)} · ${relativeTimeAgo(lastWh.created_at)}
+ ` : ''}
+
+
+
+
+
+
Health dos nós
+
+
+
+
VM112 Portal
+
+ HTTP ${vm112.http_status ?? '—'}
+ Service ${esc(vm112.vm112?.service || vm112.error || '—')}
+ API ${onboard.vm112_api?.reachable ? 'OK' : 'offline'}
+
+
+
+
VM122 Desk
+
+ Integração ${esc(intStatus)}
+ Gap ${gapMin != null ? `${gapMin} min` : '—'}
+ Webhook ${esc(lastWh.event || '—')}
+
+
+
+
VM104 Wazuh
+
+ API ${wazuh.http_status ?? '—'}
+ Regra level ≥ 10
+ Status ${wazuhOk ? 'online' : 'check'}
+
+
+
+
+
+
+
`;
+
+ 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 = `Erro SOC: ${esc(e.message)}
`;
+ }
+}
+
+async function renderInfra() {
+ const el = document.getElementById('infra-content');
+ el.innerHTML = 'Verificando…
';
+ 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 statusCls = health.status === 'ok' ? 'ok' : health.status === 'critical' ? 'escalated' : 'assisting';
+ const alerts = (health.alerts || []).map((a) =>
+ `${esc(a.message)} `
+ ).join('') || 'Nenhum alerta ';
+ el.innerHTML = `
+
+
+
SOC — Integração VM112
+ ${esc(health.status || '—')}
+
+
+ Último webhook ${last ? esc(last.event) : '—'}
+ Domínio ${last?.domain ? esc(last.domain) : '—'}
+ Há quanto tempo ${gap}
+ VM112 API ${onboard.vm112_api?.reachable ? 'OK' : esc(onboard.vm112_api?.error || 'offline')}
+
+
+
+ Testar webhook
+ Atualizar
+
+
Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.
+
+
+
+
VM112 — Portal Onboard
+
+ HTTP ${vm112.http_status ?? '—'}
+ Service ${esc(vm112.vm112?.service || vm112.error || '—')}
+
+
+
+
VM104 — Wazuh SOC
+
+ API ${wazuh.http_status ?? '—'}
+ Integração webhook level ≥ 10 → VM122
+
+
+
+
Integrações ativas
+
${esc(JSON.stringify(integrations, null, 2))}
+
`;
+ 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());
+ } catch (e) {
+ el.innerHTML = `Erro: ${esc(e.message)}
`;
+ }
+}
+
+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' && !state.openPanelTestRunning) 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 = `Erro ao iniciar (${esc(err.message)}). Voltar ao login
`;
+ }
+ }
+})();
diff --git a/ligbox-ops-platform/docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md b/ligbox-ops-platform/docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md
new file mode 100644
index 0000000..4ba9fd1
--- /dev/null
+++ b/ligbox-ops-platform/docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md
@@ -0,0 +1,56 @@
+# KB — Portal de gerenciamento abria wizard Concluído (2026-06-19)
+
+**Sistema:** Wizard VM112 · `onboard.ligbox.com.br`
+**Severidade:** P1 UX — botão errado no passo Concluído
+**Reportado por:** Roger
+**Estado:** ✅ Corrigido em produção + espelho CT130
+
+---
+
+## Sintoma
+
+No passo **Concluído**, o botão **«Portal de gerenciamento»** (`href=/admin`) reabria o **wizard no passo final** em vez do painel **DomainAdmin** (Gerente de Domínio).
+
+## Causa raiz
+
+O host `onboard.ligbox.com.br` usa build **`VITE_WIZARD_ONLY=1`** (`main-wizard.jsx`), que **não tinha rota `/admin`**.
+
+O `RouterApp.jsx` (site completo `ligbox.com.br`) mapeia `/admin` → `DomainAdmin`, mas o entry wizard-only caía sempre em ` ` (wizard).
+
+## Comportamento correcto (spec)
+
+| Botão | Destino | Notas |
+|-------|---------|-------|
+| Abrir webmail | `https://mail.{domínio}/` | Nova aba |
+| Portal de gerenciamento | `/admin` → **DomainAdmin** | Login `admin@domínio` + senha onboarding |
+| Finalizar | `ligbox.com.br/#self-service` | Limpa sessão wizard |
+
+Ver também: Spec 025 § Passo Concluído; chat bruto OPS jun/2025 («Abrir painel do domínio →»).
+
+## Correção
+
+`main-wizard.jsx` — antes do render do wizard:
+
+```javascript
+if (path === "/admin/forgot-password") → ForgotDomainPassword
+else if (path === "/admin" || path.startsWith("/admin/")) → DomainAdmin
+```
+
+Deploy: `npm run build:wizard` + `ligbox-wizard.service`.
+
+## Outras correcções na mesma sessão
+
+- `clientSettings.js` — texto SMTP legado em `sessionStorage` (IP partilhado / Ibytera)
+- `onboarding.py` — `smtp_note` canónico sem IP partilhado
+- Título clientes: Thunderbird / Outlook / **iPhone / Android**
+- Desk VM122: card **Ligbox Datacenter — Node VM001** + sync domínios audit
+
+## Verificação
+
+1. Concluir onboarding (ou passo 4 com conta criada)
+2. Clicar **Portal de gerenciamento**
+3. Deve abrir **login DomainAdmin**, não o wizard Concluído
+
+## Ficheiros espelho (CT130)
+
+`deploy/vm112-wizard/frontend/src/main-wizard.jsx`
diff --git a/ligbox-ops-platform/docs/anais-referencia/INDICE_ANAIS.md b/ligbox-ops-platform/docs/anais-referencia/INDICE_ANAIS.md
index adfdc81..661d21f 100644
--- a/ligbox-ops-platform/docs/anais-referencia/INDICE_ANAIS.md
+++ b/ligbox-ops-platform/docs/anais-referencia/INDICE_ANAIS.md
@@ -129,3 +129,4 @@ Ver `INDICE_MODELAGEM_BRUTA.txt` em `/root/ligbox-ops-platform-chat-bruto/`:
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
CHAT_BRUTO__
```
+- [[20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX]] — Portal /admin reabria wizard Concluído (VM112)
diff --git a/ligbox-ops-platform/docs/vms/vm112/SPEC_FUNCIONAL_VM112_LIGBOX_ONBOARD.md b/ligbox-ops-platform/docs/vms/vm112/SPEC_FUNCIONAL_VM112_LIGBOX_ONBOARD.md
new file mode 100644
index 0000000..6828bc8
--- /dev/null
+++ b/ligbox-ops-platform/docs/vms/vm112/SPEC_FUNCIONAL_VM112_LIGBOX_ONBOARD.md
@@ -0,0 +1,400 @@
+# Spec funcional — VM112 Ligbox Onboard
+
+**Data:** 2026-06-04
+**Host:** `mail.dratcoin.com` · `10.10.10.112`
+**Produto:** **Ligbox Onboard** (linha **Ligbox Mail**)
+**Repo:** `ibytera-mail-portal` · produção `/opt/ibytera-mail-portal`
+**Família:** [FAMILIA_PRODUTOS_LIGBOX_MAIL.md](../product/FAMILIA_PRODUTOS_LIGBOX_MAIL.md)
+
+---
+
+## 1. Identidade da VM
+
+| Campo | Valor |
+|-------|--------|
+| **VMID Proxmox** | 112 |
+| **Hostname** | `mail.dratcoin.com` |
+| **IP LAN** | `10.10.10.112/24` |
+| **IP público mail** | `95.216.14.146` (via CT114 / pfSense) |
+| **SSH WAN (pfSense NAT)** | `95.216.14.146:2512` → VM112:22 |
+| **SO** | Ubuntu 22.04/24.04, kernel 6.8.x |
+| **RAM** | 11 GiB |
+| **Disco** | `/dev/sda1` ~96 GB (~20 GB usados) |
+| **Papel** | EmailServer Carbonio + **Ligbox Onboard** (único nó mail com wizard hoje) |
+
+---
+
+## 2. Família de produtos nesta VM
+
+```
+Ligbox Mail
+└── Ligbox Onboard (VM112) ← este documento
+ ├── Landing Ligbox (/)
+ ├── Wizard (/onboard)
+ ├── Painel domínio (/admin) — parcial
+ └── Motor Carbonio (8 domínios)
+```
+
+| Produto / módulo | Estado | Rota / interface |
+|------------------|--------|------------------|
+| **Ligbox Onboard** — wizard | ✅ Produção | `/onboard` |
+| **Ligbox** — landing marketing | ✅ | `/` |
+| **Domain Admin** — painel corporativo | 🔄 Parcial | `/admin` |
+| **Carbonio EmailServer** | ✅ | `mail.*` :443, SMTP, IMAP |
+| **Agente suporte IA** | ✅ Fases 1–3.1 | Modal no wizard + tickets |
+| **Ligbox Ops Platform** | 📋 VM113 | Integração futura API/webhooks |
+
+**UI marca (PRD-6 ✅):** cabeçalho wizard = **Ligbox Onboard** + `powered by Ibytera`.
+
+---
+
+## 3. Topologia — VMs e CTs ligados
+
+```mermaid
+flowchart TB
+ subgraph Internet
+ CF[Cloudflare API]
+ Users[Clientes / Browser]
+ end
+
+ subgraph WAN["95.216.14.146"]
+ CT114[CT114 Traefik + HAProxy SNI 10.10.10.114]
+ end
+
+ subgraph LAN["10.10.10.0/24"]
+ VM112[VM112 Ligbox Onboard + Carbonio 10.10.10.112:8090 / :443]
+ VM115[VM115 ntfy 10.10.10.115:8091]
+ VM113[VM113 Ligbox Ops — planeado 10.10.10.113]
+ PVE[Proxmox 95.216.14.162 sync / workspace]
+ end
+
+ Users -->|HTTPS onboard.ibytera.com| CT114
+ Users -->|HTTPS mail.dominio.com| CT114
+ CT114 -->|HTTP :8090| VM112
+ CT114 -->|HTTPS :443| VM112
+ VM112 -->|SSH| CT114
+ VM112 -->|zmprov local| VM112
+ VM112 -->|HTTP notify| VM115
+ VM112 -.->|API CF| CF
+ VM112 -.->|webhooks futuro| VM113
+ VM113 -.->|read API| VM112
+ PVE -->|rsync deploy| VM112
+```
+
+### 3.1 Tabela de nós externos
+
+| Nó | ID | IP | Portas | Ligação desde VM112 | Função |
+|----|-----|-----|--------|---------------------|--------|
+| **Proxmox** | host | `95.216.14.162` | SSH `4422` | rsync **para** VM112 (origem workspace) | Deploy, backup projeto |
+| **CT114** | 114 | `10.10.10.114` | 443, 80, HAProxy | **SSH** `root@10.10.10.114` — edita Traefik/SNI | Terminação TLS WAN, roteamento |
+| **VM115** | 115 | `10.10.10.115` | `8091` | **HTTP** `ntfy_internal_url` | Push notificações (`ntfy.ligbox.com.br`) |
+| **VM113** | 113 | `10.10.10.113` (planeado) | TBD | **Futuro:** webhooks + API read | Ligbox Ops Platform |
+| **pfSense** | VM? | `10.10.10.1` LAN | API `10443` | Indirecto (NAT) | Firewall / WAN |
+| **Cloudflare** | SaaS | API | HTTPS | Token em `secrets/cloudflare.token` | Zonas DNS conta Ibytera |
+| **LLM** | SaaS | — | HTTPS | `secrets/llm.env`, `kimi.env` | Gemini (primário), Kimi, OpenAI, Anthropic |
+
+**Nota:** Acesso SSH à VM112 pela rede: `ssh -p 2512 root@95.216.14.146` (via pfSense WAN (95.216.14.146)).
+
+---
+
+## 4. Serviços em execução (systemd)
+
+### 4.1 Ligbox Onboard
+
+| Unidade | Estado | Descrição |
+|---------|--------|-----------|
+| `ibytera-mail-portal.service` | **active** | FastAPI + SPA estática, porta **8090** |
+
+```ini
+ExecStart=.../uvicorn app.main:app --host 0.0.0.0 --port 8090
+WorkingDirectory=/opt/ibytera-mail-portal
+After=carbonio-appserver.service
+```
+
+Health: `GET http://127.0.0.1:8090/api/onboarding/health` → `{"status":"ok"}`
+
+### 4.2 Carbonio (EmailServer)
+
+**~40 unidades** `carbonio-*` + sidecars. Principais:
+
+| Serviço | Função |
+|---------|--------|
+| `carbonio-appserver` | Core mail (Java) |
+| `carbonio-nginx` | Proxy webmail HTTPS :443 |
+| `carbonio-postfix` | SMTP :25, :587 |
+| `carbonio-openldap` | Diretório |
+| `carbonio-milter` / `carbonio-antivirus` | Segurança mail |
+| `carbonio-memcached` | Cache |
+
+**zmprov:** `/opt/zextras/bin/zmprov` (utilizador `zextras`)
+
+### 4.3 Portas em escuta (resumo)
+
+| Porta | Processo | Uso |
+|-------|----------|-----|
+| **8090** | uvicorn | Ligbox Onboard API + SPA |
+| **443** | carbonio-nginx | Webmail / admin Carbonio |
+| **25, 587** | postfix | SMTP |
+| **993** | nginx | IMAPS |
+| **7071** | java | Admin SOAP (local/LAN) |
+| **8080** | java | Appserver interno |
+
+---
+
+## 5. Domínios Carbonio (produção mail)
+
+| Domínio | Webmail (público) | Portal registry |
+|---------|-------------------|-----------------|
+| `betinplace.com` | `mail.betinplace.com` | — |
+| `betinsport.com` | `mail.betinsport.com` | — |
+| `diarissima.com` | `mail.diarissima.com` | ✅ `diarissima.com.json` |
+| `dratcoin.com` | `mail.dratcoin.com` | ✅ `dratcoin.com.json` |
+| `eplacebets.com` | `mail.eplacebets.com` | — |
+| `ibytera.com` | `mail.ibytera.com` | — |
+| `iofficebooks.com` | `mail.iofficebooks.com` | — |
+| `myvexx.com` | `mail.myvexx.com` | — |
+
+**SNI CT114** (`sni_vm112.lst`): todos os `mail.*` acima.
+**Cert LE VM112:** `mail-vm112-multi` + legado `mail.ligbox.com.br`
+**zimbraPublicServiceHostname (global):** `mail.ligbox.com.br` — hostname por domínio via `zmprov` no onboard.
+
+---
+
+## 6. URLs públicas
+
+| URL | Destino | TLS |
+|-----|---------|-----|
+| https://onboard.ibytera.com/onboard | CT114 → VM112:8090 | LE (CT114) |
+| https://onboard.ibytera.com/ | Landing + rotas SPA | idem |
+| https://onboard.ibytera.com/api/docs | OpenAPI | idem |
+| https://mail.{dominio}/ | CT114 → VM112:443 | LE multi-SAN |
+| http://10.10.10.112:8090 | LAN directo (debug) | — |
+| https://ntfy.ligbox.com.br | VM115 (push público) | — |
+
+**DNS:** registos `A` mail/onboard → `95.216.14.146` (sem proxy CF no mail).
+
+---
+
+## 7. Estrutura de diretórios
+
+### 7.1 Código e deploy (`/opt/ibytera-mail-portal`)
+
+```
+/opt/ibytera-mail-portal/
+├── backend/app/
+│ ├── main.py # FastAPI, monta SPA
+│ ├── config.py # Settings + secrets
+│ ├── routers/
+│ │ ├── onboarding.py # Wizard API
+│ │ ├── domain_admin.py # /admin API
+│ │ ├── corporate.py
+│ │ └── portal_auth.py
+│ └── services/
+│ ├── carbonio.py # zmprov, vhost nginx
+│ ├── infrastructure.py # SSH CT114, certbot
+│ ├── cloudflare.py
+│ ├── support_agent.py / support_tickets.py
+│ ├── domain_registry.py
+│ └── ...
+├── frontend/
+│ ├── src/App.jsx # Ligbox Onboard wizard
+│ ├── src/ligbox/ # Landing /
+│ └── dist/ # Build servido pelo uvicorn
+├── secrets/ # NÃO rsync (tokens)
+│ ├── cloudflare.token
+│ ├── llm.env
+│ └── kimi.env
+├── scripts/sync-all.sh # Proxmox → 112 → Obsidian → LAPTOP
+├── docs/
+│ ├── architecture/ # Este spec
+│ └── product/ # Família Ligbox Mail
+├── .env # Config não-secreta
+└── .venv/ # Python 3.12
+```
+
+### 7.2 Dados runtime (`/var/lib/ibytera-mail-portal`)
+
+| Pasta | Conteúdo |
+|-------|----------|
+| `domains/` | JSON por domínio onboarded (`*.json`) |
+| `portal_users/` | Utilizadores portal / sessões |
+| `tickets/` | Tickets suporte IA |
+| `company_profiles/` | Perfil empresa wizard |
+
+### 7.3 Carbonio
+
+| Pasta | Uso |
+|-------|-----|
+| `/opt/zextras/` | Binários Carbonio |
+| `/opt/zextras/conf/nginx/` | **carbonio-nginx** vhosts |
+| `/opt/zextras/log/` | Logs mail (futuro SOC) |
+| `/etc/letsencrypt/live/mail-vm112-multi/` | Cert multi-domínio |
+
+### 7.4 CT114 (remoto via SSH)
+
+| Ficheiro | Uso |
+|----------|-----|
+| `/root/traefik/dynamic.yml` | Routers Traefik (onboard + mail.*) |
+| `/root/traefik/haproxy-mail-sni/maps/sni_vm112.lst` | Mapa SNI → backend 112 |
+| Docker: `traefik`, `mail-sni-proxy` | Edge HTTPS |
+
+---
+
+## 8. API Ligbox Onboard (resumo)
+
+**Prefixo:** `/api/onboarding` · sessão: header `X-Onboarding-Session`
+
+| Grupo | Endpoints principais |
+|-------|---------------------|
+| Sessão | `POST /session/reset` |
+| Domínio | `POST /validate-domain` |
+| DNS CF | `POST /dns/cloudflare/provision-zone`, `POST /dns/cloudflare/apply`, `GET /dns/verify/{domain}` |
+| Conta | `POST /account/create` |
+| Infra | `GET /infrastructure/status/{domain}`, `POST /infrastructure/provision` |
+| Suporte | `POST /support/ticket`, `POST .../message`, `POST .../escalate` |
+| Perfil | `GET/PUT /company-profile`, `POST .../finalize` |
+| Log | `GET /activity-log` |
+
+**Domain admin:** `/api/domain-admin/*` (login, contas, quota, 2FA).
+
+---
+
+## 9. Pipeline infra (wizard → CT114 + VM112)
+
+Ordem em `infrastructure.provision`:
+
+1. **haproxy_sni** — adiciona `mail.{dom}` em `sni_vm112.lst` (CT114)
+2. **traefik_router** — router `mail-*-Router` em `dynamic.yml` (CT114)
+3. **certbot** — SAN em `mail-vm112-multi` (VM112)
+4. **carbonio_hostname** — `zimbraPublicServiceHostname` por domínio
+5. **carbonio_nginx_vhost** — `zmproxyconfgen` + reload nginx
+6. **webmail_https** — verificação HTTPS `mail.{dom}`
+
+**SSH:** `traefik_ssh_host = root@10.10.10.114` (chave root VM112→CT114).
+
+---
+
+## 10. Sincronização de artefactos (canais)
+
+| Canal | Caminho | Direção |
+|-------|---------|---------|
+| **Produção VM112** | `/opt/ibytera-mail-portal` | Target deploy |
+| **Workspace Proxmox** | `/root/workspace/projects/ibytera-mail-portal` | Dev + git |
+| **Obsidian** | `/root/obsidian-infra/carbonio/ibytera-mail-portal` | Documentação |
+| **LAPTOP** | `.../LAPTOP/` → `C:\LAPTOP\projetos\` | scp Windows |
+| **GitHub** | https://github.com/itecnologys/ibytera-mail-portal | Versionamento |
+
+```bash
+# Executar no host que tem o código fonte (Proxmox ou VM112):
+./scripts/sync-all.sh
+```
+
+**Excluídos do rsync:** `.venv`, `node_modules`, `secrets/`, `.env`
+
+---
+
+## 11. Segredos e integrações SaaS
+
+| Ficheiro | Integração |
+|----------|------------|
+| `secrets/cloudflare.token` | Cloudflare API (conta Ibytera) |
+| `secrets/llm.env` | Gemini, fallback chain |
+| `secrets/kimi.env` | Kimi (opcional) |
+| `.env` | IP mail, SMTP local, URLs ntfy |
+
+**Notificações:** SMTP `127.0.0.1:25` + ntfy `10.10.10.115:8091` / https://ntfy.ligbox.com.br
+
+---
+
+## 12. Frontend — rotas SPA
+
+| Path | Componente |
+|------|------------|
+| `/` | `LigboxHome` (landing) |
+| `/onboard` | Redirect → `onboard.ligbox.com.br` (ver **Spec 016**) |
+| `/admin` | `DomainAdmin` |
+| `/termos`, `/privacidade` | Legal |
+| `/sobre`, `/planos`, `/suporte`, … | `LegalStub` |
+
+### 12.1 Handoff portal → wizard (Spec 016)
+
+**Problema:** `ligbox.com.br` e `onboard.ligbox.com.br` não partilham `localStorage`.
+
+**Solução:** tripla redundância — `?planned_email=`, cookie `.ligbox.com.br`, localStorage.
+
+Documentação completa: [`SPEC_016_PORTAL_WIZARD_HANDOFF.md`](SPEC_016_PORTAL_WIZARD_HANDOFF.md)
+
+**Regra:** usar sempre `redirectToOnboard(email)` no portal; build falha se regressão (`npm run check:handoff`).
+
+
+### 12.3 Build wizard-only (`main-wizard.jsx`) — rotas obrigatórias
+
+Host: **`onboard.ligbox.com.br`** · build `npm run build:wizard`
+
+| Path | Componente | Obrigatório |
+|------|------------|-------------|
+| `/admin` | `DomainAdmin` | ✅ Sim |
+| `/admin/forgot-password` | `ForgotDomainPassword` | ✅ Sim |
+| `/assist/{session}` | `App` (ASM) | ✅ Sim |
+| `?ticket=` | `ClientTicketPortal` | ✅ Sim |
+| default | `App` (wizard) | ✅ Sim |
+
+**Bug P1 (2026-06-19):** omitir `/admin` fazia «Portal de gerenciamento» reabrir o wizard no passo Concluído. Ver KB `20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md`.
+
+### 12.2 Passo DNS — Zona DNS Ligbox (Spec 017)
+
+**Passo 2 do wizard:** escolha entre trazer DNS para a **Zona DNS Ligbox** (Cloudflare) ou manter no registrador actual.
+
+No modo externo, após clicar «Manter no provedor actual», a UI mostra **dois blocos**: zona DNS actual (público + Cloudflare) e registos necessários Ligbox.
+
+Documentação completa: [`SPEC_017_PORTAL_WIZARD_DNS_STEP.md`](SPEC_017_PORTAL_WIZARD_DNS_STEP.md)
+
+**Endpoint chave:** `GET /api/onboarding/dns/current-zone/{domain}`
+
+---
+
+## 13. Ligação futura VM113 (Ligbox Ops)
+
+| Evento (planeado) | Origem VM112 | Destino VM113 |
+|-------------------|--------------|---------------|
+| `account.created` | webhook | Desk / auditoria |
+| `infra.pending` | webhook | Alertas ops |
+| `infrastructure/status` | API read | Dashboard tenant `112` |
+
+**Regra:** VM112 **fora** do Docker Compose VM113.
+
+---
+
+## 14. Documentos relacionados
+
+| Documento | Local |
+|-----------|--------|
+| Família produtos | `docs/product/FAMILIA_PRODUTOS_LIGBOX_MAIL.md` |
+| **Handoff portal→wizard (Spec 016)** | `docs/architecture/SPEC_016_PORTAL_WIZARD_HANDOFF.md` |
+| **Passo DNS wizard (Spec 017)** | `docs/architecture/SPEC_017_PORTAL_WIZARD_DNS_STEP.md` |
+| Traefik onboard | `docs/TRAEFIK_ONBOARD.md` |
+| Release marca UI | `docs/releases/RELEASE_20260604_PRD6_MARCA_UI.md` |
+| Backlog | `BACKLOG.md` |
+| Ligbox Ops spec (VM113) | `ligbox-ops-platform/docs/architecture/` (a criar) |
+
+---
+
+## 15. Inventário rápido (comando)
+
+```bash
+# Serviços portal + carbonio
+systemctl is-active ibytera-mail-portal carbonio-nginx carbonio-appserver
+
+# Domínios
+/opt/zextras/bin/zmprov gad
+
+# Registry portal
+ls -la /var/lib/ibytera-mail-portal/domains/
+
+# CT114 SNI
+ssh root@10.10.10.114 cat /root/traefik/haproxy-mail-sni/maps/sni_vm112.lst
+```
+
+---
+
+*Gerado em 2026-06-04 a partir do estado live da VM112 (`mail.dratcoin.com`).*
diff --git a/ligbox-ops-platform/specs/025-wizard-onboarding-continuity/spec.md b/ligbox-ops-platform/specs/025-wizard-onboarding-continuity/spec.md
index eb5d71a..1210059 100644
--- a/ligbox-ops-platform/specs/025-wizard-onboarding-continuity/spec.md
+++ b/ligbox-ops-platform/specs/025-wizard-onboarding-continuity/spec.md
@@ -152,3 +152,27 @@ Webhook: `account.reconciled` (novo) ou `account.created` com `reconciled: true`
2. Clica «Continuar activação» → passo 4 sem erro 400
3. Portal admin registado; gate Traefik sincronizado
4. Ticket Desk recebe evento; Bloqueios Carbonio **não** aparece para este caso
+
+---
+
+## Passo Concluído — CTAs (UI)
+
+**Adicionado:** 2026-06-19 · correção bug rota `/admin`
+
+No passo **Concluído** (índice 4), três acções **independentes**:
+
+| CTA | Comportamento | Não confundir com |
+|-----|---------------|-------------------|
+| **Abrir webmail** | Nova aba → `https://mail.{domínio}/` | — |
+| **Portal de gerenciamento** | Nova aba → `/admin` (**DomainAdmin**) | ≠ wizard Concluído |
+| **Finalizar** | Redirect `ligbox.com.br/#self-service` + `clearOnboardingSession()` | ≠ `/admin` |
+
+### Build wizard-only (`onboard.ligbox.com.br`)
+
+O entry **`main-wizard.jsx`** DEVE registar `/admin` → `DomainAdmin` (não só `RouterApp.jsx` da landing).
+
+**Regressão conhecida (corrigida 2026-06-19):** sem esta rota, `/admin` re-renderizava ` ` e o utilizador via o passo Concluído outra vez.
+
+**KB:** `docs/anais-referencia/20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX.md`
+**Mirror código:** `deploy/vm112-wizard/frontend/src/main-wizard.jsx`
+