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… +

+ )} +
+ + Abrir webmail + + + Portal de gerenciamento + + +
+
+ ) +} 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 ` + `; + }).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

+ +
+ ${(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 || '—')} +
`} +
+
+ + + ${rows} +
FaseRegistadoΔ faseAcumulado
+
+
`; +} + +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 ` +
  1. + +
    + ${esc(evt)} + ${e.stage ? `${esc(e.stage)}` : ''} + ${prevDur}${fromStart} +
    ${fmtDate(at)}
    +
    +
  2. `; + } + ) + .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 ` + `; +} + +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 ` + `; +} + +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) => + `` + ).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 ? `` : ''} + ${canAct && !isAssisting ? `` : ''} + ${isAssisting ? `` : ''} + ${!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 ` + + + + ${ranking.slice(0, 8).map((r, i) => ` + + + + + + + + `).join('')} + +
    #TécnicoAssumidosEscaladosAcçõesScore
    ${i + 1}${esc(r.username)}${r.assumidos}${r.escalados}${r.acoes}${r.score}
    `; +} + +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)}` : ''}

    +
    + + + ${rows || ''} +
    FunçãoNomeTipoConteúdo
    Sem registos para este domínio.
    +
    `; +} + +function htmlCloudflareDnsCard(dns) { + if (!dns) { + return ` + `; + } + if (dns.error && !dns.records?.length) { + return ` + `; + } + 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 ` + `; +} + +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 ` + `; +} + +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) => ` + `).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. +

    +
    + + +
    + ${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 ``; +} + +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 ``; +} + +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 ` + `; +} + +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 = ` +
    +
    +
    Eventos nas últimas 24h
    +
    ${wizardSecVBarSvg(eventBars.length ? eventBars : [{ label: 'Nenhum', short: '—', value: 0, color: WIZARD_SEC_COLORS.na }])}
    +
    +
    +
    Risco actual
    +
    + ${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})
    • +
    +
    +
    +
    +
    Ameaças por vetor
    +
    ${wizardSecVBarSvg(vectorBars)}
    +
    +
    +
    IPs com atividade
    +
    + ${topIps.length ? topIps.map(([ip, n], i) => ` +
    + ${i + 1} + ${esc(ip)} + ${n} evt +
    `).join('') : '

    Nenhum IP registado

    '} +
    +
    +
    +
    Risco por categoria
    +
    ${wizardSecHBarHtml(riskBars)}
    +
    +
    +
    Relatório de ameaças
    +
    + + + ${threatRows || ''} +
    AmeaçaNívelSessãoIPHora
    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 ? `
      ${issueLines}
    ` : '

    Nenhum incidente de acesso nas últimas 24h

    '} +
    ${dashboardGrid}
    +
    + + ${standalone ? '' : ''} +
    +
    + +
    +
    +
    O que monitorizamos
    +
    +

    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
    +
    +
      +
    1. Input bloqueado / CSP — Anote hash + IP. Repetição ≥3×/10 min → escale. Provável ataque, não erro de cliente.
    2. +
    3. Handoff rejeitado — Cliente legítimo refaz login. Mesmo IP repetido → scraping (ticket automático).
    4. +
    5. Correlacionar — Tickets → Onboard → hash da sessão. Compare com funil.
    6. +
    7. Takeover — Só com cliente confirmado. Alerta Alto: validar identidade antes de ver credenciais.
    8. +
    9. Falso positivo — Domínios com caracteres especiais podem gerar input_warn.
    10. +
    11. Escalação — Mesmo IP em várias sessões bloqueadas → Chefe Ops / firewall.
    12. +
    +
    +
    +
    +
    + ${standalone ? '' : ` + `}`; +} + +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 ` + `; + }).join(''); + body.innerHTML = ` +
    +
    +

    Ligbox Datacenter — Node VM001

    +

    Saúde do wizard, domínios em onboarding, DNS, certificados e Carbonio

    + ${window.DeskModules?.isEnabled('wizard-security') ? '' : ''} +
    +
    +
    ${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 = ` + +
    +
    +

    ${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)} + + + ${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)}

    + `; + 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.

    + `; + 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(` + `); + }); + 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 = '

    Carregando sessão…

    '; + 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 = `

    Erro: ${esc(e.message)}

    `; + } +} + +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 = '

    Carregando…

    '; + 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) + ? `` + : ``) : ''} +
    + ${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 = `

    Erro: ${esc(e.message)}

    `; + } +} + +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 = ` +
    + + + ${rows || ''} +
    IDOrigemEventoAgente/DomínioRefData
    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 +
      +
    1. O onboarding falhou porque o e-mail já existe no Carbonio (conta órfã de processo abandonado).
    2. +
    3. Confirme o e-mail exacto e a sua senha Desk (não a do Carbonio nem root).
    4. +
    5. A ação remove apenas a conta Carbonio (zmprov da) — domínio, DNS e portal mantêm-se.
    6. +
    7. Peça ao cliente para repetir «Criar conta» no wizard com o mesmo e-mail.
    8. +
    9. Dois técnicos a resolver em paralelo: só o primeiro consegue; o outro vê «já resolvido».
    10. +
    +
    `; +} + +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 ` +
    +

    Confirme o e-mail e a sua senha Desk para executar zmprov da na VM112.

    +
    + + + +
    + +
    `; +} + +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}) +
    + + + ${resolvedRows || ''} +
    IDE-mailDomínioResolvido porQuandoTicket
    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 +
      +
    1. Input bloqueado → anote hash + IP; se repetido, escale.
    2. +
    3. Handoff rejeitado → cliente deve refazer login; ticket escalado automático.
    4. +
    5. Clique na linha para abrir a sessão em Tickets.
    6. +
    +
    +
    + + + ${rows || ''} +
    NívelEventoSessãoDomínioIPDetalheQuando
    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 ``; +} + +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.

    + + + + + + + + ${rows || ''} +
    JobDomínioStatusUsuárioDesk removidoQuandoVM112
    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 = ` +
    + + + ${(data.tenants || []).map((t) => ` + + + + + + + `).join('')} + +
    IDNomeIPPapelDesde
    ${t.id}${esc(t.name)}${esc(t.ip)}${esc(t.role)}${fmtDate(t.created_at)}
    +
    `; + } 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) => ``) + .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) => + `` + ).join(''); + return ``; +} + +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(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)}
    +
    +
    + + + + + ${user.totp_enabled ? ` +
    +

    2FA ativo — o usuário pode recuperar no login ou você pode resetar aqui.

    + +
    ` : ''} + +
    + + +
    +
    `; + + 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(u.display_name || u.username)} + ${esc(u.email || u.username)} +
    +
    + + ${roleBadgeHtml(u.role)} + ${mfaBadgeHtml(u)} + ${fmtRelative(u.last_login_at)} + ${u.active ? 'ativo' : 'inativo'} + + + + `).join(''); + + el.innerHTML = ` +
    +
    +
    +

    Equipe Ligbox

    +

    Gestão de acessos ao Support Desk

    +
    + +
    + +
    +
    ${users.length}membros
    +
    ${activeCount}ativos
    +
    ${mfaCount}com 2FA
    +
    ${inactiveCount}inativos
    +
    + +
    + + + + +
    + +
    + + + + + + + + + + + + + ${rows || ''} + +
    MembroPerfilSegurançaÚltimo loginEstado
    Nenhum membro encontrado
    +

    ${filtered.length} de ${users.length} membros

    +
    +
    `; + + 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) => ` + `).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 +
    + +
    + + + +
    +
    `).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

    +
    + + + ${historyRows} +
    E-mailEstadoPerfilAtualizado
    +
    ` : ''} +
    `; + 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 = ` + `; + 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. +

    +
    + + +
    +
    `; + + 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.

    +
    + +
    +
    `; + 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'))} +
    +
      ${steps || '
    • '}
    +

    + 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 +

    +
    + +
    +
    `; + 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.

    ' : ''} +
    + +
    +
    `; + 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 = ` +
    +
    +
    + +

    SOC Operations Center

    + VM112 → VM122 · atualizado ${esc(nowLabel)} · refresh 15s +
    +
    + + + +
    +
    + +
    +
    + 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 ? ` + + + + ${feedEvents.map((ev, i) => { + const p = ev.payload || {}; + const sev = socEventSeverity(ev.event_type); + const isNew = flashNew && i === 0; + return ` + + + + + + `; + }).join('')} + +
    EventoDomínioHora
    ${esc(SOC_EVENT_LABELS[ev.event_type] || ev.event_type)}${esc(p.domain || ev.domain || '—')}${relativeTimeAgo(ev.created_at)}
    ` : '

    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 ` + `; + }).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')}
    +
    +
      ${alerts}
    +
    + + +
    +

    Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.

    +
    +
    +

    OpenPanel API — Re-engenharia Ligbox

    +

    Spec 028 · simule contas/domínios · duração ~1–3 min por conta.

    +
    + + + + + + + + + + + + +
    +
    + +
    +
    +
    +

    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` +