docs(vm112/vm122): espelho fixes wizard /admin + Desk VM001 + KB 2026-06-19

- deploy/vm112-wizard: main-wizard DomainAdmin route, clientSettings, FinishToolbar
- deploy/vm122-desk: card Ligbox Datacenter Node VM001, audit sync
- Spec 025: secao Passo Concluido CTAs
- KB: Portal de gerenciamento reabria wizard Concluido
This commit is contained in:
Ligbox Obsidian Vault 2026-06-19 21:52:09 +00:00
parent c9930dc8f8
commit 4248e3694c
13 changed files with 7280 additions and 0 deletions

View file

@ -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**

View file

@ -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),
}

View file

@ -0,0 +1,32 @@
export default function FinishToolbar({ webmailUrl, adminUrl, pending, onFinish }) {
return (
<div className="wcl-finish-actions">
{pending && (
<p className="wcl-finish-actions__hint" role="status">
Ativando webmail em segundo plano
</p>
)}
<div className="wcl-finish-actions__row">
<a
href={webmailUrl}
className="wcl-finish-chip wcl-finish-chip--primary"
target="_blank"
rel="noopener noreferrer"
>
Abrir webmail
</a>
<a
href={adminUrl}
className="wcl-finish-chip"
target="_blank"
rel="noopener noreferrer"
>
Portal de gerenciamento
</a>
<button type="button" className="wcl-finish-chip" onClick={onFinish}>
Finalizar
</button>
</div>
</div>
)
}

View file

@ -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
}

View file

@ -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(
<React.StrictMode>
<ForgotDomainPassword />
</React.StrictMode>,
)
} else if (path === '/admin' || path.startsWith('/admin/')) {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<DomainAdmin />
</React.StrictMode>,
)
} else if (ticketFromUrl && !assistSessionId) {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<ClientTicketPortal ticketId={ticketFromUrl} sessionId={sessionFromUrl || ''} />
</React.StrictMode>,
)
} else {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<App
assistSessionId={assistSessionId}
asmMode={Boolean(assistSessionId)}
assistTakeoverToken={assistTakeoverToken}
/>
</React.StrictMode>,
)
}

View file

@ -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**

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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 `<App />` (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`

View file

@ -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 \
<caminho.jsonl> CHAT_BRUTO_<NOME>_<YYYYMMDD> <transcript-uuid>
```
- [[20260619_WIZARD_PORTAL_ADMIN_ROUTE_FIX]] — Portal /admin reabria wizard Concluído (VM112)

View file

@ -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 13.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<br/>10.10.10.114]
end
subgraph LAN["10.10.10.0/24"]
VM112[VM112 Ligbox Onboard + Carbonio<br/>10.10.10.112:8090 / :443]
VM115[VM115 ntfy<br/>10.10.10.115:8091]
VM113[VM113 Ligbox Ops — planeado<br/>10.10.10.113]
PVE[Proxmox 95.216.14.162<br/>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`).*

View file

@ -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 `<App />` 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`