feat(dns): Spec 037 DNS Viewer — Desk, Console e patches Wizard V4

Entrega read-only de apontamentos DNS (Cloudflare, OpenPanel BIND, público)
no Desk e Console /admin/dominio, com spec, scripts de rollback e patches
VM112 para painel lateral no passo DNS do onboarding (deploy wizard pendente).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ligbox Spec Hub 2026-06-25 16:36:25 +00:00
parent 7e4920a4bb
commit 038fb8f7ce
54 changed files with 8074 additions and 5 deletions

View file

@ -0,0 +1,36 @@
# Spec 037 V4 — Wizard DNS Viewer
Painel **read-only** no **passo DNS existente** (step 1) — **não** é menu nem página nova.
## Backend
```bash
# Na VM112
python3 /opt/ligbox-ops-platform/deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py
# Requer OPS_INTERNAL_TOKEN + DESK_API_URL no env wizard
systemctl restart ligbox-wizard # ou docker compose restart backend
```
Endpoint: `GET /api/onboarding/dns/viewer/{domain}`
Header: `X-Onboarding-Session` (sessão wizard)
Proxy → Desk `GET /api/v1/dns/viewer/{domain}` (token interno).
`edit_links` removidos na resposta wizard (cliente).
## Frontend
```bash
python3 /opt/ligbox-ops-platform/deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py
cd /opt/ligbox-wizard/frontend && npm run build
```
## UI
Painel lateral abaixo das opções DNS:
- Modo (Ligbox / externo / OpenPanel)
- Tabela registos (max 12 linhas)
- Secção «Serão aplicados» se `planned_records`
## Rollback
Ver `specs/037-dns-multi-cloudflare-orchestration/deploy/DNS-VIEWER-ROLLBACK.md` § VM112.

View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Deploy Spec 037 construção — Fase A + stubs D na VM112."""
from pathlib import Path
BASE = Path("/opt/ligbox-spec-hub/repos/ligbox-ops-platform")
WIZARD = Path("/opt/ligbox-wizard")
REPO = BASE / "projects/wizard/backend/app"
FILES = [
(REPO / "services/project_identity.py", WIZARD / "backend/app/services/project_identity.py"),
(REPO / "services/cf_client_accounts.py", WIZARD / "backend/app/services/cf_client_accounts.py"),
(REPO / "routers/project.py", WIZARD / "backend/app/routers/project.py"),
]
for src, dst in FILES:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
print(f"copied {dst.name}")
# main.py — register project router
main = WIZARD / "backend/app/main.py"
text = main.read_text(encoding="utf-8")
if "project" not in text.split("from app.routers import")[1].split("\n")[0]:
text = text.replace(
"from app.routers import admin_accounts, admin_domains, assist, corporate, domain_admin, onboarding, portal_auth, telemetry",
"from app.routers import admin_accounts, admin_domains, assist, corporate, domain_admin, onboarding, portal_auth, project, telemetry",
)
if "project.router" not in text:
text = text.replace(
"app.include_router(onboarding.router, prefix=\"/api\")",
"app.include_router(onboarding.router, prefix=\"/api\")\napp.include_router(project.router, prefix=\"/api\")",
)
main.write_text(text, encoding="utf-8")
print("main.py patched")
# onboarding.py — phase D endpoints + webhook ligbox_project_email
onb = WIZARD / "backend/app/routers/onboarding.py"
ot = onb.read_text(encoding="utf-8")
if "cf_client_accounts" not in ot:
ot = ot.replace(
"from app.services import dns_byo_vault",
"from app.services import dns_byo_vault, cf_client_accounts, project_identity",
)
PHASE_D = '''
class ProvisionClientAccountRequest(BaseModel):
domain: str = Field(..., min_length=3, max_length=253)
display_name: str | None = None
class HandoffManagerRequest(BaseModel):
domain: str
manager_email: str = Field(..., min_length=5)
def _project_email_for_domain(domain: str) -> str | None:
ident = project_identity.get_project_identity(domain.lower().strip())
return ident.get("ligbox_project_email") if ident else None
@router.post("/dns/cloudflare/provision-client-account")
def provision_client_cf_account(body: ProvisionClientAccountRequest, request: Request):
"""Fase D — conta CF dedicada (fallback zona partilhada até Tenant)."""
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
try:
ligbox_email = _project_email_for_domain(domain)
result = cf_client_accounts.provision_client_account(
domain,
ligbox_project_email=ligbox_email,
account_name=body.display_name or domain,
)
ops_webhook.emit_event(
"onboard.cf_account.provisioned",
domain=domain,
session_id=get_session_from_request(request),
data={
"ligbox_project_email": ligbox_email,
"zone_id": result.get("zone", {}).get("id"),
"provision_mode": result.get("record", {}).get("provision_mode"),
},
)
return result
except CloudflareError as e:
raise HTTPException(502, str(e)) from e
@router.post("/dns/cloudflare/handoff-manager")
def handoff_cf_manager(body: HandoffManagerRequest, request: Request):
"""Fase D — convite gestor domínio na conta CF."""
get_session_from_request(request)
domain = normalize_domain(body.domain)
result = cf_client_accounts.handoff_manager(domain, body.manager_email)
ops_webhook.emit_event(
"onboard.cf_handoff.requested",
domain=domain,
session_id=get_session_from_request(request),
data=result,
)
return result
'''
if "/dns/cloudflare/provision-client-account" not in ot:
anchor = "@router.get(\"/session/password-status\")"
ot = ot.replace(anchor, PHASE_D + anchor)
# apply webhook
if "onboard.dns.applied" not in ot:
ot = ot.replace(
' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n return {\n "domain": domain,',
' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n ops_webhook.emit_event(\n "onboard.dns.applied",\n domain=domain,\n session_id=sid,\n data={\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "ligbox_account_id": ligbox_acct,\n "zone_id": zone_id,\n "verification_ready": verification.get("ready"),\n },\n )\n return {\n "domain": domain,',
)
# account create webhook extension
if "onboard.account.created" not in ot:
ot = ot.replace(
' activity_log.ok("Processo de onboarding finalizado", source="portal")',
' ops_webhook.emit_event(\n "onboard.account.created",\n domain=domain,\n session_id=sid,\n data={\n "email": email,\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "verified": verified,\n },\n )\n activity_log.ok("Processo de onboarding finalizado", source="portal")',
)
onb.write_text(ot, encoding="utf-8")
print("onboarding.py patched")
import ast
ast.parse(main.read_text())
ast.parse(onb.read_text())
print("syntax OK")

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Deploy 037 construction on VM112 — run ON the host."""
from pathlib import Path
import ast
WIZARD = Path("/opt/ligbox-wizard")
TMP = Path("/tmp")
for name in ["project_identity.py", "cf_client_accounts.py"]:
dst = WIZARD / "backend/app/services" / name
dst.write_text((TMP / name).read_text(encoding="utf-8"), encoding="utf-8")
print("copied", name)
(WIZARD / "backend/app/routers/project.py").write_text(
(TMP / "project_router.py").read_text(encoding="utf-8"), encoding="utf-8"
)
print("copied project.py")
main = WIZARD / "backend/app/main.py"
text = main.read_text(encoding="utf-8")
old_imp = (
"from app.routers import admin_accounts, admin_domains, assist, corporate, "
"domain_admin, onboarding, portal_auth, telemetry"
)
new_imp = old_imp.replace(", telemetry", ", project, telemetry")
if "project, telemetry" not in text:
text = text.replace(old_imp, new_imp)
if "project.router" not in text:
text = text.replace(
'app.include_router(onboarding.router, prefix="/api")',
'app.include_router(onboarding.router, prefix="/api")\n'
'app.include_router(project.router, prefix="/api")',
)
main.write_text(text, encoding="utf-8")
print("main.py ok")
onb = WIZARD / "backend/app/routers/onboarding.py"
ot = onb.read_text(encoding="utf-8")
if "cf_client_accounts" not in ot:
ot = ot.replace(
"from app.services import dns_byo_vault",
"from app.services import dns_byo_vault, cf_client_accounts, project_identity",
)
PHASE_D = '''
class ProvisionClientAccountRequest(BaseModel):
domain: str = Field(..., min_length=3, max_length=253)
display_name: str | None = None
class HandoffManagerRequest(BaseModel):
domain: str
manager_email: str = Field(..., min_length=5)
def _project_email_for_domain(domain: str) -> str | None:
ident = project_identity.get_project_identity(domain.lower().strip())
return ident.get("ligbox_project_email") if ident else None
@router.post("/dns/cloudflare/provision-client-account")
def provision_client_cf_account(body: ProvisionClientAccountRequest, 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
try:
ligbox_email = _project_email_for_domain(domain)
result = cf_client_accounts.provision_client_account(
domain,
ligbox_project_email=ligbox_email,
account_name=body.display_name or domain,
)
ops_webhook.emit_event(
"onboard.cf_account.provisioned",
domain=domain,
session_id=get_session_from_request(request),
data={
"ligbox_project_email": ligbox_email,
"zone_id": result.get("zone", {}).get("id"),
"provision_mode": result.get("record", {}).get("provision_mode"),
},
)
return result
except CloudflareError as e:
raise HTTPException(502, str(e)) from e
@router.post("/dns/cloudflare/handoff-manager")
def handoff_cf_manager(body: HandoffManagerRequest, request: Request):
get_session_from_request(request)
domain = normalize_domain(body.domain)
result = cf_client_accounts.handoff_manager(domain, body.manager_email)
ops_webhook.emit_event(
"onboard.cf_handoff.requested",
domain=domain,
session_id=get_session_from_request(request),
data=result,
)
return result
'''
if "/dns/cloudflare/provision-client-account" not in ot:
ot = ot.replace('@router.get("/session/password-status")', PHASE_D + '@router.get("/session/password-status")')
if "onboard.dns.applied" not in ot:
ot = ot.replace(
' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n return {\n "domain": domain,',
' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n ops_webhook.emit_event(\n "onboard.dns.applied",\n domain=domain,\n session_id=sid,\n data={\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "ligbox_account_id": ligbox_acct,\n "zone_id": zone_id,\n "verification_ready": verification.get("ready"),\n },\n )\n return {\n "domain": domain,',
)
if "onboard.account.created" not in ot:
ot = ot.replace(
' activity_log.ok("Processo de onboarding finalizado", source="portal")',
' ops_webhook.emit_event(\n "onboard.account.created",\n domain=domain,\n session_id=sid,\n data={\n "email": email,\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "verified": verified,\n },\n )\n activity_log.ok("Processo de onboarding finalizado", source="portal")',
)
onb.write_text(ot, encoding="utf-8")
ast.parse(main.read_text())
ast.parse(onb.read_text())
print("deploy-037-vm112 OK")

View file

@ -0,0 +1,36 @@
# Spec 037 — Catálogo contas Cloudflare Ligbox (VM112)
# Copiar para /opt/ligbox-wizard/dns-accounts.yaml
# Tokens: secrets/cloudflare-{id}.token (NÃO commitar)
version: "1.0"
probe_order:
- ligit
- itecnologys
- ibytera
# Conta onde o wizard cria zonas NOVAS de clientes (caminho Cloudflare Ligbox)
default_provision_account: ibytera
accounts:
- id: ligit
label: "Ligbox Ligit"
admin_email: admin@ligit.com.br
cloudflare_account_id: "PREENCHER_ACCOUNT_ID_CF"
token_file: secrets/cloudflare-ligit.token
- id: itecnologys
label: "Ligbox iTecnologys"
admin_email: admin@itecnologys.com
cloudflare_account_id: "PREENCHER_ACCOUNT_ID_CF"
token_file: secrets/cloudflare-itecnologys.token
- id: ibytera
label: "Ligbox Ibytera"
admin_email: ibytera@gmail.com
cloudflare_account_id: "2d504d7a78dd787696fe1c703fb001c1"
token_file: secrets/cloudflare-ibytera.token
# Migrar token actual de secrets/cloudflare.token
# Legacy fallback (remover após migração)
legacy_token_file: secrets/cloudflare.token
legacy_account_id: "2d504d7a78dd787696fe1c703fb001c1"

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
from pathlib import Path
import re
APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx")
text = APP.read_text(encoding="utf-8")
pattern = r" async function choosePortalDns\(\) \{.*?\n \}\n\n async function chooseByoDns"
replacement = r''' async function choosePortalDns() {
setDnsChoice('portal')
setError(null)
startBusy('dns_zone')
try {
const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`))
setDnsResolve(resolved)
const guide = await api('/onboarding/dns/cloudflare/provision-zone', {
method: 'POST',
body: JSON.stringify({ domain }),
})
setPortalGuide(guide)
const cf = guide.status || (await refreshCfStatus())
setCfStatus(cf)
markActionDone('choosePortalDns')
if (guide.verification) {
setVerification(guide.verification)
setResult({ type: 'dns', data: { verification: guide.verification } })
markActionDone('verifyZone')
markActionDone('applyPortalDns')
} else if (cf?.zone_in_account && cf?.can_apply_mail_records) {
markActionDone('verifyZone')
const data = await api('/onboarding/dns/cloudflare/apply', {
method: 'POST',
body: JSON.stringify({
domain,
zone_id: guide.zone_id || resolved.zone_id,
mail_aliases: sanitizeMailAliases(mailAliasInputs, domain),
}),
})
setVerification(data.verification)
setResult({ type: 'dns', data })
markActionDone('applyPortalDns')
}
} catch (e) {
setError(e.message)
setDnsChoice(null)
setActionsDone((prev) => ({ ...prev, choosePortalDns: false }))
} finally {
stopBusy()
}
}
async function chooseByoDns'''
new_text, n = re.subn(pattern, replacement, text, count=1, flags=re.DOTALL)
if n != 1:
raise SystemExit(f"replace failed: {n}")
APP.write_text(new_text, encoding="utf-8")
print("choosePortalDns fixed")

View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Replace provision_cloudflare_zone body on VM112."""
from pathlib import Path
ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py")
CF = Path("/opt/ligbox-wizard/backend/app/services/cloudflare.py")
text = ROUTER.read_text(encoding="utf-8")
# imports
if "provision_ligbox_zone" not in text:
text = text.replace(
"from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload",
"from app.services.dns_account_registry import (\n"
" pick_provision_account,\n"
" provision_ligbox_zone,\n"
" resolve_ligbox_zone,\n"
" resolve_payload,\n"
")",
)
OLD_START = '@router.post("/dns/cloudflare/provision-zone")\ndef provision_cloudflare_zone(body: DomainRequest, request: Request):'
OLD_END = '@router.get("/dns/portal-onboarding/{domain}")'
start = text.find(OLD_START)
end = text.find(OLD_END)
if start < 0 or end < 0:
raise SystemExit("anchors not found")
NEW_FUNC = '''@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
match = resolve_ligbox_zone(domain)
if match:
activity_log.info(
f"Zona {domain} já na conta {match.account.id} — sem create",
source="cloudflare",
)
payload = _portal_onboarding_payload(domain, zone_created=False)
payload["zone_id"] = match.zone_id
payload["ligbox_account_id"] = match.account.id
payload["dns_mode"] = match.dns_mode
payload["message"] = (
f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})."
)
if nameservers := wizard_nameservers(match.zone):
payload["nameservers"] = nameservers
payload["status"]["nameservers"] = nameservers
return payload
provision_acct = pick_provision_account()
if not provision_acct:
raise HTTPException(
400,
"Nenhuma conta Cloudflare Ligbox configurada. Ver dns-accounts.yaml e secrets/.",
)
try:
activity_log.info(
f"Criar zona cliente {domain} na CF Ligbox ({provision_acct.id})",
source="cloudflare",
)
provision_acct.client().verify_token()
prov = provision_ligbox_zone(domain, account=provision_acct)
ligbox_acct = prov["account"]
cf = ligbox_acct.client()
zone = prov["zone"]
created = prov["created"]
sandbox = is_cloudflare_sandbox(cf.token)
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["ligbox_account_id"] = ligbox_acct.id
payload["dns_mode"] = f"ligbox_cf_{ligbox_acct.id}"
payload["sandbox"] = sandbox
payload["message"] = (
f"Domínio {domain} {'criado' if created else 'já existia'} na Cloudflare Ligbox "
f"({ligbox_acct.label}). "
+ (
"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
'''
text = text[:start] + NEW_FUNC + text[end:]
ROUTER.write_text(text, encoding="utf-8")
cf_text = CF.read_text(encoding="utf-8")
if "account_id: str | None = None" not in cf_text:
cf_text = cf_text.replace(
' def ensure_zone(self, domain: str) -> dict:\n'
' """Garante que a zona existe na conta Ibytera; cria se necessário."""\n'
" zone = self.get_zone_by_name(domain)\n"
" if zone:\n"
' return {"zone": zone, "created": False}\n'
" zone = self.create_zone(domain)\n"
' return {"zone": zone, "created": True}',
' def ensure_zone(self, domain: str, account_id: str | None = None) -> dict:\n'
' """Garante que a zona existe na conta CF; cria se necessário."""\n'
" zone = self.get_zone_by_name(domain)\n"
" if zone:\n"
' return {"zone": zone, "created": False}\n'
" zone = self.create_zone(domain, account_id=account_id)\n"
' return {"zone": zone, "created": True}',
)
CF.write_text(cf_text, encoding="utf-8")
import ast
ast.parse(ROUTER.read_text())
print("fix-provision-zone OK")

View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Spec 037 V4 — painel DNS read-only no passo DNS do wizard (App.jsx). Idempotente."""
from pathlib import Path
APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx")
text = APP.read_text(encoding="utf-8")
if "dnsViewerData" in text:
print("App.jsx: dns viewer V4 already patched")
raise SystemExit(0)
text = text.replace(
" const [showAdvancedDns, setShowAdvancedDns] = useState(false)",
" const [showAdvancedDns, setShowAdvancedDns] = useState(false)\n"
" const [dnsViewerData, setDnsViewerData] = useState(null)\n"
" const [dnsViewerLoading, setDnsViewerLoading] = useState(false)",
)
LOAD_FN = """
async function refreshDnsViewer(dom) {
const d = (dom || domain || '').trim().toLowerCase()
if (!d || d.length < 3) return
setDnsViewerLoading(true)
try {
const data = await api(`/onboarding/dns/viewer/${d}`)
setDnsViewerData(data)
} catch {
setDnsViewerData(null)
} finally {
setDnsViewerLoading(false)
}
}
"""
if "refreshDnsViewer" not in text:
text = text.replace(" async function choosePortalDns() {", LOAD_FN + " async function choosePortalDns() {")
# Refresh when instructions load
text = text.replace(
" setDnsResolve(data.resolve || null)",
" setDnsResolve(data.resolve || null)\n refreshDnsViewer(dom)",
)
text = text.replace(
" setDnsChoice('portal')\n setError(null)\n startBusy('dns_zone')",
" setDnsChoice('portal')\n setError(null)\n refreshDnsViewer(domain)\n startBusy('dns_zone')",
)
text = text.replace(
" setDnsChoice('external')\n markActionDone('chooseExternalDns')",
" setDnsChoice('external')\n refreshDnsViewer(domain)\n markActionDone('chooseExternalDns')",
)
PANEL = """
{(dnsViewerData || dnsViewerLoading) && step === 1 && (
<aside className="dns-viewer-wizard-panel" style={{
marginTop: '1rem',
padding: '0.85rem 1rem',
borderRadius: '10px',
border: '1px solid #dbe4f4',
background: '#f8fbff',
}}>
<strong>Apontamentos DNS (visualização)</strong>
{dnsViewerLoading && <p className="sub">Carregando</p>}
{!dnsViewerLoading && dnsViewerData && (
<>
<p className="sub" style={{ margin: '0.35rem 0' }}>
{dnsViewerData.mode_message || dnsViewerData.mode_label}
</p>
<p className="sub">
Modo: <code>{dnsViewerData.mode_label || dnsViewerData.dns_mode}</code>
{' · '}
{dnsViewerData.summary?.total || 0} registo(s)
</p>
<table className="data-table" style={{ fontSize: '0.82rem', marginTop: '0.5rem' }}>
<thead>
<tr><th>Tipo</th><th>Nome</th><th>Conteúdo</th></tr>
</thead>
<tbody>
{(dnsViewerData.records || []).slice(0, 12).map((r, i) => (
<tr key={i}>
<td>{r.type}</td>
<td><code>{r.name}</code></td>
<td style={{ wordBreak: 'break-all' }}>{r.content}</td>
</tr>
))}
{!(dnsViewerData.records || []).length && (
<tr><td colSpan={3}>Sem registos escolha o caminho DNS acima.</td></tr>
)}
</tbody>
</table>
{(dnsViewerData.planned_records || []).length > 0 && (
<>
<p className="sub" style={{ marginTop: '0.65rem' }}><strong>Serão aplicados (Ligbox):</strong></p>
<table className="data-table" style={{ fontSize: '0.82rem' }}>
<tbody>
{(dnsViewerData.planned_records || []).slice(0, 8).map((r, i) => (
<tr key={`p-${i}`}>
<td>{r.type}</td>
<td><code>{r.name}</code></td>
<td>{r.content}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
)}
</aside>
)}
"""
if "dns-viewer-wizard-panel" not in text:
text = text.replace(
" {dnsChoice === 'byo' && showAdvancedDns && (",
PANEL + " {dnsChoice === 'byo' && showAdvancedDns && (",
)
APP.write_text(text, encoding="utf-8")
print("App.jsx: DNS viewer V4 panel OK")

View file

@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Patch App.jsx Spec 037 — multi-conta CF + BYO."""
from pathlib import Path
APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx")
text = APP.read_text(encoding="utf-8")
if "chooseByoDns" in text and "dnsResolve" in text:
print("App.jsx already patched")
raise SystemExit(0)
text = text.replace(
" choosePortalDns: false,\n chooseExternalDns: false,",
" choosePortalDns: false,\n chooseByoDns: false,\n chooseExternalDns: false,",
)
text = text.replace(
" const [showAdvancedDns, setShowAdvancedDns] = useState(false)",
" const [showAdvancedDns, setShowAdvancedDns] = useState(false)\n const [dnsResolve, setDnsResolve] = useState(null)\n const [byoToken, setByoToken] = useState('')",
)
OLD_CHOOSE_PORTAL = """ async function choosePortalDns() {
setDnsChoice('portal')
setError(null)
startBusy('dns_zone')
try {
const guide = await api('/onboarding/dns/cloudflare/provision-zone', {
method: 'POST',
body: JSON.stringify({ domain }),
})
setPortalGuide(guide)
const cf = guide.status || (await refreshCfStatus())
setCfStatus(cf)
markActionDone('choosePortalDns')
if (guide.verification) {
setVerification(guide.verification)
setResult({ type: 'dns', data: { verification: guide.verification } })
markActionDone('verifyZone')
markActionDone('applyPortalDns')
} else if (cf?.zone_in_account && cf?.can_apply_mail_records) {
markActionDone('verifyZone')
const data = await api('/onboarding/dns/cloudflare/apply', {
method: 'POST',
body: JSON.stringify({
domain,
mail_aliases: sanitizeMailAliases(mailAliasInputs, domain),
}),
})
setVerification(data.verification)
setResult({ type: 'dns', data })
markActionDone('applyPortalDns')
}
} catch (e) {
setError(e.message)
setDnsChoice(null)
setActionsDone((prev) => ({ ...prev, choosePortalDns: false }))
} finally {
stopBusy()
}
}"""
NEW_CHOOSE_PORTAL = """ async function choosePortalDns() {
setDnsChoice('portal')
setError(null)
startBusy('dns_zone')
try {
const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`))
setDnsResolve(resolved)
if (!resolved.matched) {
throw new Error(
resolved.message ||
'Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera).',
)
}
const cf = await refreshCfStatus()
setCfStatus(cf)
markActionDone('choosePortalDns')
markActionDone('verifyZone')
const data = await api('/onboarding/dns/cloudflare/apply', {
method: 'POST',
body: JSON.stringify({
domain,
zone_id: resolved.zone_id,
mail_aliases: sanitizeMailAliases(mailAliasInputs, domain),
}),
})
setPortalGuide({
message: resolved.message,
nameservers: cf?.nameservers || [],
status: cf,
ligbox_account_id: resolved.account_id,
})
setVerification(data.verification)
setResult({ type: 'dns', data })
markActionDone('applyPortalDns')
} catch (e) {
setError(e.message)
setDnsChoice(null)
setActionsDone((prev) => ({ ...prev, choosePortalDns: false }))
} finally {
stopBusy()
}
}
async function chooseByoDns() {
setDnsChoice('byo')
setError(null)
markActionDone('chooseByoDns')
setShowAdvancedDns(true)
}
async function connectByoAndApply() {
const token = byoToken.trim()
if (!token) {
setError('Cole o API Token da sua conta Cloudflare (DNS Edit na zona).')
return
}
setError(null)
startBusy('dns_byo')
try {
const conn = await api('/onboarding/dns/cloudflare/connect-custom', {
method: 'POST',
body: JSON.stringify({ domain, api_token: token }),
})
setDnsResolve({ ...conn, matched: true, dns_mode: 'byo_cloudflare' })
markActionDone('chooseByoDns')
const data = await api('/onboarding/dns/cloudflare/apply', {
method: 'POST',
body: JSON.stringify({
domain,
zone_id: conn.zone_id,
mail_aliases: sanitizeMailAliases(mailAliasInputs, domain),
}),
})
setVerification(data.verification)
setResult({ type: 'dns', data })
markActionDone('applyPortalDns')
} catch (e) {
setError(e.message)
setActionsDone((prev) => ({ ...prev, chooseByoDns: false, applyPortalDns: false }))
} finally {
stopBusy()
}
}"""
text = text.replace(OLD_CHOOSE_PORTAL, NEW_CHOOSE_PORTAL)
text = text.replace(
""" const DNS_ACTION_KEYS = {
choosePortalDns: false,
chooseExternalDns: false,""",
""" const DNS_ACTION_KEYS = {
choosePortalDns: false,
chooseByoDns: false,
chooseExternalDns: false,""",
)
text = text.replace(
" if (dnsChoice === 'portal') markActionDone('choosePortalDns')\n if (dnsChoice === 'external') markActionDone('chooseExternalDns')",
" if (dnsChoice === 'portal') markActionDone('choosePortalDns')\n if (dnsChoice === 'byo') markActionDone('chooseByoDns')\n if (dnsChoice === 'external') markActionDone('chooseExternalDns')",
)
text = text.replace(
" const data = await api(`/onboarding/dns/instructions/${dom}`)\n setInstructions(data)\n setCfStatus(data.cloudflare)",
" const data = await api(`/onboarding/dns/instructions/${dom}`)\n setInstructions(data)\n setCfStatus(data.cloudflare)\n setDnsResolve(data.resolve || null)",
)
text = text.replace(
" if (step !== 1 || !instructions || showAdvancedDns || dnsChoice === 'external' || asmMode) return",
" if (step !== 1 || !instructions || showAdvancedDns || dnsChoice === 'external' || dnsChoice === 'byo' || asmMode) return",
)
text = text.replace(
" if (portalDnsApplied || loading || autoDnsAttempted.current) return\n autoDnsAttempted.current = true",
" if (portalDnsApplied || loading || autoDnsAttempted.current) return\n const resolve = instructions?.resolve || dnsResolve\n if (resolve && !resolve.matched) return\n autoDnsAttempted.current = true",
)
text = text.replace(
" dns_mode: dnsChoice === 'portal' ? 'Cloudflare Ligbox' : 'Provedor externo',",
" dns_mode:\n dnsChoice === 'portal'\n ? dnsResolve?.dns_mode || cfStatus?.dns_mode || 'ligbox_cf'\n : dnsChoice === 'byo'\n ? 'byo_cloudflare'\n : 'external_registrar',",
)
# Advanced mode choice grid — add BYO button and update portal label
OLD_GRID = """ <ActionDoneButton
block
done={actionsDone.choosePortalDns}
label="Trazer DNS para o portal"
hint="Zona DNS Ligbox + apontamentos de email."
disabled={loading || actionsDone.chooseExternalDns}
busy={loading && !actionsDone.choosePortalDns}
onClick={choosePortalDns}
attentionId="choosePortalDns"
/>
<ActionDoneButton
block
secondary
done={actionsDone.chooseExternalDns}
label="Manter no provedor atual"
hint="Apontamentos manuais no provedor atual."
disabled={loading || actionsDone.choosePortalDns}
busy={loading && !actionsDone.chooseExternalDns}
onClick={chooseExternalDns}
attentionId="chooseExternalDns"
/>"""
NEW_GRID = """ <ActionDoneButton
block
done={actionsDone.choosePortalDns}
label={
dnsResolve?.matched
? `Cloudflare Ligbox (${dnsResolve.account_id})`
: 'Cloudflare Ligbox (indisponível)'
}
hint={
dnsResolve?.matched
? dnsResolve.message
: 'Domínio não está nas contas ligit / itecnologys / ibytera.'
}
disabled={
loading ||
actionsDone.chooseExternalDns ||
actionsDone.chooseByoDns ||
!dnsResolve?.matched
}
busy={loading && !actionsDone.choosePortalDns}
onClick={choosePortalDns}
attentionId="choosePortalDns"
/>
<ActionDoneButton
block
secondary
done={actionsDone.chooseByoDns}
label="Minha conta Cloudflare"
hint="API Token com DNS Edit na zona do domínio."
disabled={loading || actionsDone.choosePortalDns || actionsDone.chooseExternalDns}
busy={loading && !actionsDone.chooseByoDns}
onClick={chooseByoDns}
attentionId="chooseByoDns"
/>
<ActionDoneButton
block
secondary
done={actionsDone.chooseExternalDns}
label="Registrador / DNS externo"
hint="Apontamentos manuais (domínio a registar ou NS noutro sítio)."
disabled={loading || actionsDone.choosePortalDns || actionsDone.chooseByoDns}
busy={loading && !actionsDone.chooseExternalDns}
onClick={chooseExternalDns}
attentionId="chooseExternalDns"
/>"""
text = text.replace(OLD_GRID, NEW_GRID)
BYO_PANEL = """
{dnsChoice === 'byo' && showAdvancedDns && (
<div className="message" style={{ background: '#f4f0ff', color: '#3d2d6b' }}>
<strong>Sua conta Cloudflare (BYO)</strong>
<p className="sub" style={{ margin: '0.35rem 0 0' }}>
Crie um API Token com permissão <strong>Zone DNS Edit</strong> apenas na zona{' '}
<code>{domain}</code>.
</p>
<label className="sub" style={{ display: 'block', marginTop: '0.75rem' }}>
API Token Cloudflare
<input
type="password"
className="input"
style={{ marginTop: '0.35rem', width: '100%' }}
value={byoToken}
onChange={(e) => setByoToken(e.target.value)}
autoComplete="off"
/>
</label>
<div className="actions" style={{ marginTop: '0.75rem' }}>
<ActionDoneButton
done={portalDnsApplied}
label="Validar token e aplicar apontamentos"
disabled={loading || portalDnsApplied || !byoToken.trim()}
busy={loading && !portalDnsApplied}
onClick={connectByoAndApply}
attentionId="applyPortalDns"
/>
</div>
</div>
)}
"""
if "dnsChoice === 'byo'" not in text:
text = text.replace(
" {dnsChoice === 'external' && showAdvancedDns && (",
BYO_PANEL + " {dnsChoice === 'external' && showAdvancedDns && (",
)
# Simple mode message when not matched
SIMPLE_MSG = """
{!dnsResolve?.matched && !portalDnsApplied && (
<WizardStatusPanel variant="warn" icon={ShieldCheck} title="Escolha como apontar o DNS">
<p>
Este domínio não está nas contas Cloudflare Ligbox. Use{' '}
<strong>Minha conta Cloudflare</strong> ou{' '}
<strong>Registrador / DNS externo</strong> (botão técnico abaixo).
</p>
</WizardStatusPanel>
)}
"""
if "não está nas contas Cloudflare Ligbox" not in text:
text = text.replace(
" {portalDnsApplied && (",
SIMPLE_MSG + " {portalDnsApplied && (",
)
APP.write_text(text, encoding="utf-8")
print("App.jsx patched OK")

View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Frontend Spec 037b — provision-zone cria zona cliente na CF Ligbox."""
from pathlib import Path
APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx")
text = APP.read_text(encoding="utf-8")
OLD = """ async function choosePortalDns() {
setDnsChoice('portal')
setError(null)
startBusy('dns_zone')
try {
const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`))
setDnsResolve(resolved)
if (!resolved.matched) {
throw new Error(
resolved.message ||
'Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera).',
)
}
const cf = await refreshCfStatus()
setCfStatus(cf)
markActionDone('choosePortalDns')
markActionDone('verifyZone')
const data = await api('/onboarding/dns/cloudflare/apply', {
method: 'POST',
body: JSON.stringify({
domain,
zone_id: resolved.zone_id,
mail_aliases: sanitizeMailAliases(mailAliasInputs, domain),
}),
})
setPortalGuide({
message: resolved.message,
nameservers: cf?.nameservers || [],
status: cf,
ligbox_account_id: resolved.account_id,
})
setVerification(data.verification)
setResult({ type: 'dns', data })
markActionDone('applyPortalDns')
} catch (e) {
setError(e.message)
setDnsChoice(null)
setActionsDone((prev) => ({ ...prev, choosePortalDns: false }))
} finally {
stopBusy()
}
}"""
NEW = """ async function choosePortalDns() {
setDnsChoice('portal')
setError(null)
startBusy('dns_zone')
try {
const resolved = dnsResolve || (await api(`/onboarding/dns/resolve/${domain}`))
setDnsResolve(resolved)
const guide = await api('/onboarding/dns/cloudflare/provision-zone', {
method: 'POST',
body: JSON.stringify({ domain }),
})
setPortalGuide(guide)
const cf = guide.status || (await refreshCfStatus())
setCfStatus(cf)
markActionDone('choosePortalDns')
if (guide.verification) {
setVerification(guide.verification)
setResult({ type: 'dns', data: { verification: guide.verification } })
markActionDone('verifyZone')
markActionDone('applyPortalDns')
} else if (cf?.zone_in_account && cf?.can_apply_mail_records) {
markActionDone('verifyZone')
const data = await api('/onboarding/dns/cloudflare/apply', {
method: 'POST',
body: JSON.stringify({
domain,
zone_id: guide.zone_id || resolved.zone_id,
mail_aliases: sanitizeMailAliases(mailAliasInputs, domain),
}),
})
setVerification(data.verification)
setResult({ type: 'dns', data })
markActionDone('applyPortalDns')
}
} catch (e) {
setError(e.message)
setDnsChoice(null)
setActionsDone((prev) => ({ ...prev, choosePortalDns: false }))
} finally {
stopBusy()
}
}"""
if "provision-zone" in text and "if (!resolved.matched)" in text:
text = text.replace(OLD, NEW)
print("choosePortalDns restored with provision-zone")
# Auto mode: allow for new domains too
text = text.replace(
" const resolve = instructions?.resolve || dnsResolve\n if (resolve && !resolve.matched) return\n autoDnsAttempted.current = true",
" autoDnsAttempted.current = true",
)
# Ligbox button always enabled when can provision
text = text.replace(
""" label={
dnsResolve?.matched
? `Cloudflare Ligbox (${dnsResolve.account_id})`
: 'Cloudflare Ligbox (indisponível)'
}
hint={
dnsResolve?.matched
? dnsResolve.message
: 'Domínio não está nas contas ligit / itecnologys / ibytera.'
}
disabled={
loading ||
actionsDone.chooseExternalDns ||
actionsDone.chooseByoDns ||
!dnsResolve?.matched
}""",
""" label={
dnsResolve?.matched
? `Cloudflare Ligbox (${dnsResolve.account_id})`
: `Cloudflare Ligbox (${dnsResolve?.provision_account_id || 'nova zona'})`
}
hint={
dnsResolve?.matched
? dnsResolve.message
: 'Criar zona do seu domínio na Cloudflare gerenciada pela Ligbox.'
}
disabled={
loading ||
actionsDone.chooseExternalDns ||
actionsDone.chooseByoDns ||
dnsResolve?.can_provision_ligbox === false
}""",
)
# Simple mode warning
text = text.replace(
""" {!dnsResolve?.matched && !portalDnsApplied && (
<WizardStatusPanel variant="warn" icon={ShieldCheck} title="Escolha como apontar o DNS">
<p>
Este domínio não está nas contas Cloudflare Ligbox. Use{' '}
<strong>Minha conta Cloudflare</strong> ou{' '}
<strong>Registrador / DNS externo</strong> (botão técnico abaixo).
</p>
</WizardStatusPanel>
)}""",
""" {!dnsResolve?.matched && !portalDnsApplied && (
<WizardStatusPanel variant="info" icon={ShieldCheck} title="Domínio novo na Cloudflare Ligbox">
<p>
Vamos criar a zona do seu domínio na Cloudflare gerenciada pela Ligbox
{dnsResolve?.provision_account_id
? ` (conta ${dnsResolve.provision_account_id})`
: ''}
. Depois altere os nameservers no registrador.
</p>
</WizardStatusPanel>
)}""",
)
APP.write_text(text, encoding="utf-8")
print("App.jsx 037b patched OK")

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Spec 037 V4 — GET /onboarding/dns/viewer/{domain} na VM112. Idempotente."""
from __future__ import annotations
import os
from pathlib import Path
ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py")
text = ROUTER.read_text(encoding="utf-8")
VIEWER_ENDPOINT = '''
@router.get("/dns/viewer/{domain}")
async def dns_viewer_onboarding(domain: str, request: Request):
"""Painel DNS read-only — proxy Desk (Spec 037 V4). Sem edit_links para cliente."""
get_session_from_request(request)
domain = normalize_domain(domain)
try:
validate_primary_domain(domain)
except ValueError as e:
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
import httpx
desk = os.getenv("DESK_API_URL", "http://10.10.10.122:8080").rstrip("/")
token = os.getenv("OPS_INTERNAL_TOKEN", "")
headers = {"Accept": "application/json"}
if token:
headers["X-Ops-Internal-Token"] = token
url = f"{desk}/api/v1/dns/viewer/{domain}?email_service=true&include_public=true"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
res = await client.get(url, headers=headers)
if res.status_code >= 400:
raise HTTPException(res.status_code, res.text[:500])
payload = res.json()
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"Desk DNS viewer indisponível: {e}") from e
payload["edit_links"] = []
payload["viewer_context"] = "wizard_onboarding"
return payload
'''
if "/dns/viewer/" not in text:
anchor = '@router.get("/dns/resolve/{domain}")'
if anchor not in text:
anchor = '@router.get("/dns/instructions/{domain}")'
text = text.replace(anchor, VIEWER_ENDPOINT + anchor)
ROUTER.write_text(text, encoding="utf-8")
print("onboarding.py: dns/viewer endpoint added")
else:
print("onboarding.py: dns/viewer already present")

View file

@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Aplica patch Spec 037 em onboarding.py na VM112. Idempotente."""
from pathlib import Path
ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py")
text = ROUTER.read_text(encoding="utf-8")
IMPORT_OLD = "from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers"
IMPORT_NEW = """from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers
from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload
from app.services import dns_byo_vault"""
if "dns_account_registry" not in text:
text = text.replace(IMPORT_OLD, IMPORT_NEW)
MODEL_ANCHOR = "class CloudflareApplyRequest(BaseModel):"
MODEL_INSERT = """class CloudflareConnectCustomRequest(BaseModel):
domain: str
api_token: str = Field(..., min_length=20, max_length=512)
"""
if "CloudflareConnectCustomRequest" not in text:
text = text.replace(MODEL_ANCHOR, MODEL_INSERT + MODEL_ANCHOR)
HELPER = '''
def _cf_client_for_domain(domain: str, session_id: str | None = None):
"""Resolve cliente CF: conta Ligbox, BYO sessão, ou token legacy."""
match = resolve_ligbox_zone(domain)
if match:
return match.account.client(), match.zone_id, match.dns_mode, match.account.id
if session_id:
byo = dns_byo_vault.load_byo_token(session_id, domain)
if byo:
return CloudflareDNS(token=byo.api_token), byo.zone_id, "byo_cloudflare", None
if settings.cloudflare_api_token:
cf = CloudflareDNS()
zone = cf.get_zone_by_name(domain)
if zone:
return cf, zone["id"], "ligbox_cf_legacy", None
return None, None, None, None
'''
if "_cf_client_for_domain" not in text:
anchor = "def _cloudflare_zone_status(domain: str) -> dict:"
text = text.replace(anchor, HELPER + anchor)
# Replace _cloudflare_zone_status body start
OLD_STATUS = '''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."
),
}'''
NEW_STATUS = '''def _cloudflare_zone_status(domain: str) -> dict:
domain = domain.lower().strip()
resolved = resolve_payload(domain)
match = resolve_ligbox_zone(domain)
if match:
cf = match.account.client()
try:
cf.verify_token()
except CloudflareError:
pass
zone = match.zone
zone_status = zone.get("status")
nameservers = wizard_nameservers(zone)
return {
"domain": domain,
"cloudflare_configured": True,
"zone_in_account": True,
"can_apply_mail_records": True,
"zone_id": match.zone_id,
"zone_status": zone_status,
"nameservers": nameservers,
"ligbox_account_id": match.account.id,
"ligbox_account_label": f"{match.account.label} ({match.account.admin_email})",
"dns_mode": match.dns_mode,
"resolve": resolved,
"managed_zones": [],
"portal_onboarding_required": zone_status != "active",
"message": (
f"Zona {domain} na conta {match.account.label} ({zone_status})."
),
}
legacy_ok = bool(settings.cloudflare_api_token)
return {
"domain": domain,
"cloudflare_configured": legacy_ok,
"zone_in_account": False,
"can_apply_mail_records": False,
"zone_id": None,
"zone_status": None,
"nameservers": [],
"ligbox_account_id": None,
"dns_mode": None,
"resolve": resolved,
"managed_zones": [],
"portal_onboarding_required": True,
"paths_available": resolved.get("paths_available") or ["byo", "external"],
"message": resolved.get("message")
or "Domínio fora das contas Ligbox — use BYO Cloudflare ou registrador.",
}'''
if "ligbox_account_id" not in text:
text = text.replace(OLD_STATUS, NEW_STATUS)
RESOLVE_ENDPOINT = '''
@router.get("/dns/resolve/{domain}")
def dns_resolve_account(domain: str):
"""Spec 037 — em qual conta Ligbox (ligit/itecnologys/ibytera) está a zona."""
domain = domain.lower().strip().rstrip(".")
try:
activity_log.info(f"Resolver conta CF Ligbox: {domain}", source="cloudflare")
return resolve_payload(domain)
except CloudflareError as e:
activity_log.error(str(e), source="cloudflare")
raise HTTPException(502, f"Erro Cloudflare: {e}") from e
@router.post("/dns/cloudflare/connect-custom")
def connect_custom_cloudflare(body: CloudflareConnectCustomRequest, request: Request):
"""BYO — token API Cloudflare do cliente (zona já na conta dele)."""
sid = 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
cf = CloudflareDNS(token=body.api_token.strip())
try:
cf.verify_token()
zone = cf.get_zone_by_name(domain)
if not zone:
raise HTTPException(
422,
detail={
"code": "zone_not_in_customer_account",
"domain": domain,
"message": f"Zona {domain} não encontrada com este API Token.",
},
)
if sid:
dns_byo_vault.save_byo_token(sid, domain, body.api_token.strip(), str(zone["id"]))
activity_log.ok(f"BYO Cloudflare ligado: {domain}", source="cloudflare")
return {
"domain": domain,
"matched": True,
"dns_mode": "byo_cloudflare",
"zone_id": zone.get("id"),
"zone_status": zone.get("status"),
"nameservers": wizard_nameservers(zone),
"message": "Conta Cloudflare do cliente validada. Pode aplicar apontamentos.",
}
except CloudflareError as e:
activity_log.error(f"BYO Cloudflare falhou: {e}", source="cloudflare")
raise HTTPException(401, f"Token inválido: {e}") from e
'''
if "/dns/resolve/" not in text:
text = text.replace(
'@router.get("/dns/cloudflare/status/{domain}")',
RESOLVE_ENDPOINT + '@router.get("/dns/cloudflare/status/{domain}")',
)
# Fix indentation typo in patch if any - the RESOLVE has wrong indent on cf line
text = text.replace(" cf = CloudflareDNS(token=body.api_token.strip())", " cf = CloudflareDNS(token=body.api_token.strip())")
# provision-zone guard
OLD_PROVISION_START = ''' cf = CloudflareDNS()
try:
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
cf.verify_token()
result = cf.ensure_zone(domain)'''
NEW_PROVISION_START = ''' match = resolve_ligbox_zone(domain)
if match:
activity_log.info(
f"Zona {domain} já na conta {match.account.id} — sem create",
source="cloudflare",
)
payload = _portal_onboarding_payload(domain, zone_created=False)
payload["zone_id"] = match.zone_id
payload["ligbox_account_id"] = match.account.id
payload["dns_mode"] = match.dns_mode
payload["message"] = (
f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})."
)
if nameservers := wizard_nameservers(match.zone):
payload["nameservers"] = nameservers
payload["status"]["nameservers"] = nameservers
return payload
raise HTTPException(
status_code=422,
detail={
"code": "zone_not_in_ligbox_accounts",
"domain": domain,
"message": (
"Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera). "
"Use API Token da sua Cloudflare ou apontamentos no registrador."
),
"paths_available": ["byo", "external"],
},
)
cf = CloudflareDNS()
try:
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
cf.verify_token()
result = cf.ensure_zone(domain)'''
if "zone_not_in_ligbox_accounts" not in text:
text = text.replace(OLD_PROVISION_START, NEW_PROVISION_START)
OLD_APPLY = ''' 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"]'''
NEW_APPLY = ''' sid = get_session_from_request(request)
cf, zone_id, dns_mode, ligbox_acct = _cf_client_for_domain(domain, sid)
if not cf or not zone_id:
activity_log.error(f"Sem conta CF para {domain}", source="cloudflare")
raise HTTPException(
status_code=422,
detail={
"code": "zone_not_in_account",
"domain": domain,
"message": (
f"Zona {domain} não está nas contas Ligbox nem BYO. "
"Ligue BYO ou use registrador externo."
),
"paths_available": ["byo", "external"],
},
)
try:
activity_log.info(
f"Aplicar registos mail ({dns_mode or 'cf'}): {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
if body.zone_id:
zone_id = body.zone_id'''
if "ligbox_acct" not in text:
text = text.replace(OLD_APPLY, NEW_APPLY)
# apply return add dns_mode
OLD_APPLY_RET = ' return {"domain": domain, "zone_id": zone_id, "applied": applied, "verification": verification}'
NEW_APPLY_RET = ''' return {
"domain": domain,
"zone_id": zone_id,
"dns_mode": dns_mode,
"ligbox_account_id": ligbox_acct,
"applied": applied,
"verification": verification,
}'''
if '"dns_mode": dns_mode' not in text:
text = text.replace(OLD_APPLY_RET, NEW_APPLY_RET)
# dns instructions paths
OLD_PATHS = ''' "dns_paths": {
"portal": "Trazer DNS para o portal (Cloudflare conta Ligbox)",
"external": "Manter DNS no provedor atual (apontamentos manuais)",
},'''
NEW_PATHS = ''' "dns_paths": {
"ligbox": "Cloudflare Ligbox (ligit / itecnologys / ibytera) — apontamentos automáticos",
"byo": "Sua conta Cloudflare (API Token)",
"external": "Registrador / DNS externo (apontamentos manuais)",
},
"resolve": resolve_payload(domain),'''
if '"byo"' not in text.split("dns_paths")[1][:200]:
text = text.replace(OLD_PATHS, NEW_PATHS)
ROUTER.write_text(text, encoding="utf-8")
print("onboarding.py patched OK")

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Wizard UI — provision-email + painel credenciais proj_*."""
from pathlib import Path
import re
APP = Path("/opt/ligbox-wizard/frontend/src/App.jsx")
text = APP.read_text(encoding="utf-8")
if "projectCredentials" not in text:
text = text.replace(
" const [byoToken, setByoToken] = useState('')",
" const [byoToken, setByoToken] = useState('')\n"
" const [projectCredentials, setProjectCredentials] = useState(null)",
)
PROVISION_FN = '''
async function ensureProjectEmail() {
if (projectCredentials?.ligbox_project_email) return projectCredentials
const data = await api('/onboarding/project/provision-email', {
method: 'POST',
body: JSON.stringify({ domain, display_name: companyLabelFromDomain(domain) }),
})
setProjectCredentials(data)
return data
}
'''
if "ensureProjectEmail" not in text:
text = text.replace(" async function choosePortalDns() {", PROVISION_FN + " async function choosePortalDns() {")
# inject ensureProjectEmail at start of choosePortalDns
if "await ensureProjectEmail()" not in text:
text = text.replace(
" startBusy('dns_zone')\n try {\n const resolved = dnsResolve",
" startBusy('dns_zone')\n try {\n const proj = await ensureProjectEmail()\n const resolved = dnsResolve",
)
PANEL = '''
{projectCredentials?.ligbox_project_email && (
<WizardStatusPanel variant="info" icon={ShieldCheck} title="Credenciais do projeto Ligbox">
<p>
E-mail: <strong>{projectCredentials.ligbox_project_email}</strong>
<br />
Webmail: <a href="https://mail.ligbox.com.br/" target="_blank" rel="noreferrer">mail.ligbox.com.br</a>
</p>
{projectCredentials.password_available && !projectCredentials.password && (
<ActionDoneButton
label="Mostrar senha do projeto (uma vez)"
onClick={async () => {
try {
const rev = await api(`/onboarding/project/${domain}/reveal-password`, { method: 'POST' })
setProjectCredentials((p) => ({ ...p, password: rev.password, password_shown: true }))
} catch (e) {
setError(e.message)
}
}}
/>
)}
{projectCredentials.password && (
<p className="sub">
Senha: <code>{projectCredentials.password}</code> guarde agora.
</p>
)}
</WizardStatusPanel>
)}
'''
if "Credenciais do projeto Ligbox" not in text:
text = text.replace(
" {portalDnsApplied && (",
PANEL + " {portalDnsApplied && (",
)
APP.write_text(text, encoding="utf-8")
print("App.jsx project panel OK")

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Spec 037b — provision-zone cria zona do cliente na CF Ligbox."""
from pathlib import Path
ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py")
REGISTRY = Path("/opt/ligbox-wizard/backend/app/services/dns_account_registry.py")
CF = Path("/opt/ligbox-wizard/backend/app/services/cloudflare.py")
# --- cloudflare ensure_zone account_id ---
cf_text = CF.read_text(encoding="utf-8")
OLD_ENSURE = """ def ensure_zone(self, domain: str) -> dict:
\"\"\"Garante que a zona existe na conta Ibytera; cria se necessário.\"\"\"
zone = self.get_zone_by_name(domain)
if zone:
return {"zone": zone, "created": False}
zone = self.create_zone(domain)
return {"zone": zone, "created": True}"""
NEW_ENSURE = """ def ensure_zone(self, domain: str, account_id: str | None = None) -> dict:
\"\"\"Garante que a zona existe na conta CF; cria se necessário.\"\"\"
zone = self.get_zone_by_name(domain)
if zone:
return {"zone": zone, "created": False}
zone = self.create_zone(domain, account_id=account_id)
return {"zone": zone, "created": True}"""
if "account_id: str | None = None" not in cf_text:
cf_text = cf_text.replace(OLD_ENSURE, NEW_ENSURE)
CF.write_text(cf_text, encoding="utf-8")
print("cloudflare.py patched")
# --- registry: ensure pick_provision_account exists (copy from monorepo if needed) ---
# handled via scp separately
# --- onboarding import ---
text = ROUTER.read_text(encoding="utf-8")
if "provision_ligbox_zone" not in text:
text = text.replace(
"from app.services.dns_account_registry import resolve_ligbox_zone, resolve_payload",
"from app.services.dns_account_registry import (\n pick_provision_account,\n provision_ligbox_zone,\n resolve_ligbox_zone,\n resolve_payload,\n)",
)
OLD_BLOCK = ''' match = resolve_ligbox_zone(domain)
if match:
activity_log.info(
f"Zona {domain} já na conta {match.account.id} — sem create",
source="cloudflare",
)
payload = _portal_onboarding_payload(domain, zone_created=False)
payload["zone_id"] = match.zone_id
payload["ligbox_account_id"] = match.account.id
payload["dns_mode"] = match.dns_mode
payload["message"] = (
f"Domínio {domain} já está na Cloudflare Ligbox ({match.account.label})."
)
if nameservers := wizard_nameservers(match.zone):
payload["nameservers"] = nameservers
payload["status"]["nameservers"] = nameservers
return payload
raise HTTPException(
status_code=422,
detail={
"code": "zone_not_in_ligbox_accounts",
"domain": domain,
"message": (
"Domínio não está nas contas Ligbox (ligit, itecnologys, ibytera). "
"Use API Token da sua Cloudflare ou apontamentos no registrador."
),
"paths_available": ["byo", "external"],
},
)
cf = CloudflareDNS()
try:
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
cf.verify_token()
result = cf.ensure_zone(domain)'''
NEW_BLOCK = ''' provision_acct = pick_provision_account()
if not provision_acct:
raise HTTPException(
400,
"Nenhuma conta Cloudflare Ligbox configurada. Ver dns-accounts.yaml e secrets/.",
)
try:
activity_log.info(
f"Provision zona cliente {domain} (conta Ligbox {provision_acct.id})",
source="cloudflare",
)
provision_acct.client().verify_token()
result = provision_ligbox_zone(domain, account=provision_acct)
match = result["account"]
cf = match.client()
result = {"zone": result["zone"], "created": result["created"]}'''
if "provision_ligbox_zone(domain" not in text:
text = text.replace(OLD_BLOCK, NEW_BLOCK)
# fix variable shadowing - after replace, need to fix zone/created extraction
text = text.replace(
" match = result["account"]\n cf = match.client()\n result = {"zone": result["zone"], "created": result["created"]}",
" ligbox_acct = result["account"]\n cf = ligbox_acct.client()\n zone = result["zone"]\n created = result["created"]",
)
# Remove duplicate zone = result["zone"] if the old code still has it - read and fix
text = text.replace(
" zone = result[\"zone\"]\n created = result[\"created\"]\n zone = result[\"zone\"]\n created = result[\"created\"]",
" zone = result[\"zone\"]\n created = result[\"created\"]",
)
# Add ligbox_account_id to payload after zone created
if 'payload["ligbox_account_id"]' not in text.split("provision_cloudflare_zone")[1][:2500]:
text = text.replace(
' payload["zone_id"] = zone.get("id")\n payload["sandbox"] = sandbox',
' payload["zone_id"] = zone.get("id")\n payload["ligbox_account_id"] = ligbox_acct.id\n payload["dns_mode"] = f"ligbox_cf_{ligbox_acct.id}"\n payload["sandbox"] = sandbox',
)
ROUTER.write_text(text, encoding="utf-8")
print("onboarding.py provision-zone patched OK")

View file

@ -0,0 +1,345 @@
"""Unified DNS viewer (read-only) — Spec 037-DNS-VIEWER."""
from __future__ import annotations
import os
import re
from typing import Any
import httpx
from app.cloudflare_dns import fetch_domain_dns
from app.collectors.dns import _dig, collect
from app.openpanel_dns import (
fetch_openpanel_bind_records,
ns_points_to_openpanel,
)
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
CF_NS_SUFFIX = ".ns.cloudflare.com"
MODE_LABELS: dict[str, str] = {
"ligbox_cf_ligit": "Cloudflare Ligbox (ligit)",
"ligbox_cf_itecnologys": "Cloudflare Ligbox (itecnologys)",
"ligbox_cf_ibytera": "Cloudflare Ligbox (ibytera)",
"ligbox_cf": "Cloudflare Ligbox",
"ligbox_cf_provision_pending": "DNS Ligbox (aguarda NS)",
"byo_cf": "Cloudflare cliente (BYO)",
"external": "DNS externo / registrador",
"registrar": "DNS externo / registrador",
"openpanel_bind": "OpenPanel BIND",
"unknown": "A determinar",
}
def _ns_list(domain: str) -> list[str]:
lines = _dig(domain, "NS")
return [re.sub(r"\.$", "", ln.lower()) for ln in lines if ln.strip()]
def _ligbox_cf_ns() -> list[str]:
return []
def _ns_match_cloudflare(ns: list[str]) -> bool:
return any(n.endswith(CF_NS_SUFFIX) for n in ns)
def _public_checks(domain: str) -> dict[str, Any]:
raw = collect(domain)
out: dict[str, Any] = {}
for key, check_id in (
("mx", "dns_mx"),
("spf", "dns_spf"),
("dkim", "dns_dkim"),
("dmarc", "dns_dmarc"),
):
item = raw.get(check_id, {})
status = item.get("status", "fail")
out[key] = {
"ok": status == "pass",
"warn": status == "warn",
"values": (item.get("evidence") or {}).get("records") or [],
"hint": item.get("message") if status != "pass" else None,
}
return out
def _records_from_public(domain: str, checks: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
mx_vals = checks.get("mx", {}).get("values") or []
for line in mx_vals[:5]:
m = re.match(r"(\d+)\s+(.+)", line.strip())
if m:
rows.append(
{
"source": "public_resolver",
"status": "actual",
"type": "MX",
"name": domain,
"content": m.group(2).rstrip("."),
"priority": int(m.group(1)),
"ttl": None,
"purpose": "mx",
"email_related": True,
}
)
for txt in (checks.get("spf", {}).get("values") or [])[:2]:
rows.append(
{
"source": "public_resolver",
"status": "actual",
"type": "TXT",
"name": domain,
"content": txt,
"priority": None,
"ttl": None,
"purpose": "spf",
"email_related": True,
}
)
for txt in (checks.get("dkim", {}).get("values") or [])[:1]:
rows.append(
{
"source": "public_resolver",
"status": "actual",
"type": "TXT",
"name": f"default._domainkey.{domain}",
"content": txt[:200],
"priority": None,
"ttl": None,
"purpose": "dkim",
"email_related": True,
}
)
for txt in (checks.get("dmarc", {}).get("values") or [])[:1]:
rows.append(
{
"source": "public_resolver",
"status": "actual",
"type": "TXT",
"name": f"_dmarc.{domain}",
"content": txt[:200],
"priority": None,
"ttl": None,
"purpose": "dmarc",
"email_related": True,
}
)
a_mail = _dig(f"mail.{domain}", "A")
for ip in a_mail[:2]:
rows.append(
{
"source": "public_resolver",
"status": "actual",
"type": "A",
"name": f"mail.{domain}",
"content": ip,
"priority": None,
"ttl": None,
"purpose": "mail-host",
"email_related": True,
}
)
return rows
async def _wizard_resolve(domain: str) -> dict[str, Any] | None:
try:
async with httpx.AsyncClient(timeout=6.0) as client:
res = await client.get(f"{VM112_API}/api/onboarding/dns/resolve/{domain}")
if res.status_code == 200:
data = res.json()
if isinstance(data, dict):
return data
except Exception:
pass
return None
def _infer_mode(
resolve: dict[str, Any] | None,
cf_zone: dict | None,
public_ns: list[str],
*,
openpanel_zone: bool = False,
openpanel_ns: bool = False,
) -> tuple[str, str, str]:
if openpanel_zone or openpanel_ns:
return "openpanel_bind", "applied", "openpanel_bind"
if resolve:
mode = str(resolve.get("dns_mode") or "unknown")
if mode.startswith("ligbox_cf"):
if cf_zone and public_ns and not _ns_match_cloudflare(public_ns):
return "ligbox_cf_provision_pending", "planned", "cf_ligbox"
return mode if mode != "unknown" else "ligbox_cf", "applied", "cf_ligbox"
if mode in ("byo_cf", "external", "registrar", "openpanel_bind"):
display = "actual" if mode != "byo_cf" else "applied"
src = "cf_byo" if mode == "byo_cf" else "public_resolver"
return mode, display, src
if cf_zone:
if public_ns and not _ns_match_cloudflare(public_ns):
return "ligbox_cf_provision_pending", "planned", "cf_ligbox"
return "ligbox_cf", "applied", "cf_ligbox"
if public_ns:
return "external", "actual", "public_resolver"
return "unknown", "actual", "public_resolver"
def _edit_links(
domain: str,
dns_mode: str,
zone: dict | None,
role: str,
authoritative: str = "",
) -> list[dict[str, Any]]:
from app.permissions import can_open_cloudflare_dns_link
links: list[dict[str, Any]] = []
if can_open_cloudflare_dns_link(role) and dns_mode.startswith("ligbox_cf"):
zone_name = (zone or {}).get("name") or domain
links.append(
{
"label": "Editar na Cloudflare (Ligbox)",
"provider": "cf_ligbox",
"url": f"https://dash.cloudflare.com/?to=/:account/{zone_name}/dns",
"roles": ["super_admin", "ops_lead", "devops", "seo"],
}
)
if dns_mode in ("external", "registrar", "unknown"):
links.append(
{
"label": "Registro.br / registrador",
"provider": "registrar",
"url": "https://registro.br",
"roles": ["super_admin", "ops_lead", "technician", "seo"],
}
)
if dns_mode == "openpanel_bind" or authoritative == "openpanel_bind":
from app.permissions import can_open_openpanel_link
if can_open_openpanel_link(role):
from app.openpanel_dns import OPENPANEL_URL
links.append(
{
"label": "Editar no OpenPanel",
"provider": "openpanel_bind",
"url": f"{OPENPANEL_URL}/domains/{domain}/dns",
"roles": ["super_admin", "ops_lead", "sales_admin", "seo"],
}
)
return links
def _mode_message(display_mode: str, dns_mode: str) -> str:
if dns_mode.startswith("ligbox_cf") and display_mode == "planned":
return (
"Estes apontamentos serão configurados na Cloudflare Ligbox "
"após delegar os nameservers."
)
if dns_mode.startswith("ligbox_cf"):
return "Apontamentos activos ou geridos na Cloudflare Ligbox."
if dns_mode in ("external", "registrar"):
return "O domínio usa DNS fora da Ligbox. Abaixo: resolução pública actual."
if dns_mode == "openpanel_bind":
return "Zona servida pelo DNS Ligbox (OpenPanel BIND)."
return "Origem DNS em análise — verifique nameservers e zona."
async def fetch_dns_viewer(
domain: str,
*,
role: str,
email_service: bool | None = None,
include_public: bool = True,
) -> dict[str, Any]:
domain = domain.lower().strip().rstrip(".")
errors: list[str] = []
resolve = await _wizard_resolve(domain)
cf_payload = await fetch_domain_dns(domain, email_service=email_service)
cf_zone = cf_payload.get("zone")
public_ns = _ns_list(domain)
op_bind = fetch_openpanel_bind_records(domain)
op_records = op_bind.get("records") or []
op_zone = bool(op_bind.get("zone_responds") or op_records)
op_ns = bool(op_bind.get("ns_on_openpanel") or ns_points_to_openpanel(domain))
ligbox_ns = []
if resolve and resolve.get("matched"):
ligbox_ns = resolve.get("nameservers") or []
dns_mode, display_mode, authoritative = _infer_mode(
resolve, cf_zone, public_ns, openpanel_zone=op_zone, openpanel_ns=op_ns
)
public_checks = _public_checks(domain) if include_public else {}
records: list[dict[str, Any]] = []
planned_records: list[dict[str, Any]] = []
if authoritative == "openpanel_bind" and op_records:
records = list(op_records)
if op_bind.get("error") and op_bind["error"] not in errors:
errors.append(op_bind["error"])
elif display_mode == "actual" and not cf_zone:
records = _records_from_public(domain, public_checks)
authoritative = "public_resolver"
elif cf_payload.get("records"):
for r in cf_payload["records"]:
records.append(
{
**r,
"source": "cf_ligbox",
"status": "applied" if display_mode == "applied" else "planned",
}
)
elif display_mode == "actual":
records = _records_from_public(domain, public_checks)
if not records and op_records and not cf_zone:
records = list(op_records)
dns_mode = "openpanel_bind"
authoritative = "openpanel_bind"
display_mode = "applied"
if display_mode == "planned" and cf_payload.get("records"):
planned_records = [{**r, "source": "planned_ligbox", "status": "planned"} for r in cf_payload["records"]]
cf_ns_expected = ligbox_ns or ([n for n in public_ns if n.endswith(CF_NS_SUFFIX)] if _ns_match_cloudflare(public_ns) else [])
return {
"domain": domain,
"dns_mode": dns_mode,
"mode_label": MODE_LABELS.get(dns_mode, dns_mode),
"display_mode": display_mode,
"authoritative_source": authoritative,
"mode_message": _mode_message(display_mode, dns_mode),
"nameservers": {
"current_public": public_ns,
"ligbox_cloudflare": cf_ns_expected,
"match_ligbox": _ns_match_cloudflare(public_ns) if public_ns else False,
},
"records": records,
"planned_records": planned_records,
"public_checks": public_checks,
"edit_links": _edit_links(domain, dns_mode, cf_zone, role, authoritative),
"instructions": None,
"resolve": resolve,
"openpanel": {
"bind_host": op_bind.get("bind_host"),
"zone_responds": op_bind.get("zone_responds"),
"ns_on_openpanel": op_bind.get("ns_on_openpanel"),
"edit_url": op_bind.get("edit_url"),
},
"zone": cf_zone,
"email_service": cf_payload.get("email_service"),
"summary": {
"total": len(records),
"planned": len(planned_records),
"email_related": sum(1 for r in records if r.get("email_related")),
},
"errors": errors + ([cf_payload["error"]] if cf_payload.get("error") else []),
"legacy_cf": cf_payload,
}

View file

@ -0,0 +1,66 @@
"""Domain admin API proxy for Ligbox Console — Spec 035 / 037 V3."""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Query
from app import auth
from app.dns_viewer import fetch_dns_viewer
from app.openpanel_dns import fetch_openpanel_bind_records
from app.permissions import can_read_dns_viewer
router = APIRouter(prefix="/api/v1/domain-console", tags=["domain-console"])
DNS_VIEWER_ENABLED = os.getenv("DNS_VIEWER_ENABLED", "1").lower() in ("1", "true", "yes")
STAFF_ROLES = frozenset(
{
"super_admin",
"ops_lead",
"devops",
"seo",
"technician",
"developer",
"noc",
"sales_admin",
"sales_support",
}
)
@router.get("/dns/viewer/{domain}")
async def domain_console_dns_viewer(
domain: str,
email_service: bool | None = Query(default=True),
include_public: bool = Query(default=True),
user: auth.DeskUser = Depends(auth.get_current_user),
):
"""DNS Viewer for Console /admin/dominio."""
if not DNS_VIEWER_ENABLED:
raise HTTPException(404, "DNS Viewer disabled")
if not can_read_dns_viewer(user.role):
raise HTTPException(403, "insufficient permissions")
domain = domain.lower().strip().rstrip(".")
payload = await fetch_dns_viewer(
domain,
role=user.role,
email_service=email_service,
include_public=include_public,
)
if user.role not in STAFF_ROLES:
payload["edit_links"] = [
link for link in payload.get("edit_links", []) if link.get("provider") != "cf_ligbox"
]
return payload
@router.get("/dns/openpanel/records")
async def domain_console_openpanel_records(
domain: str = Query(..., min_length=3),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_dns_viewer(user.role):
raise HTTPException(403, "insufficient permissions")
return fetch_openpanel_bind_records(domain.lower().strip().rstrip("."))

View file

@ -0,0 +1,316 @@
"""Sandbox Área Gerente — produção create-only, nunca apaga existentes (Spec 035)."""
from __future__ import annotations
import os
import re
import secrets
import sqlite3
import uuid
from typing import Any
import httpx
from app import domain_console_sandbox_store, vm112_domains
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
SANDBOX_DOMAIN_PREFIX = os.getenv("DOMAIN_CONSOLE_SANDBOX_PREFIX", "cenario-")
SANDBOX_ALLOWED_SUFFIX = os.getenv(
"DOMAIN_CONSOLE_SANDBOX_SUFFIX",
".ops.ligbox.com.br",
)
SANDBOX_ENABLED = os.getenv("DOMAIN_CONSOLE_SANDBOX", "1") == "1"
PROTECTED_DOMAINS = vm112_domains.PURGE_BLOCKLIST | frozenset(
{"ligbox.com.br", "itecnologys.com", "ligbox.com", "myvexx.com"}
)
_SCENARIO_DOMAIN_RE = re.compile(
rf"^{re.escape(SANDBOX_DOMAIN_PREFIX)}[a-z0-9]([a-z0-9-]{{0,61}}[a-z0-9])?{re.escape(SANDBOX_ALLOWED_SUFFIX)}$",
re.I,
)
class SandboxError(ValueError):
pass
class SandboxForbidden(PermissionError):
pass
def assert_enabled() -> None:
if not SANDBOX_ENABLED:
raise SandboxForbidden("DOMAIN_CONSOLE_SANDBOX desactivado")
def assert_no_delete(operation: str) -> None:
raise SandboxForbidden(
f"Sandbox create-only: operação «{operation}» proibida — nada existente pode ser apagado"
)
def is_scenario_domain(domain: str) -> bool:
return bool(_SCENARIO_DOMAIN_RE.match(domain.lower().strip()))
def suggest_scenario_domain(slug: str | None = None) -> str:
slug = (slug or uuid.uuid4().hex[:8]).lower()
slug = re.sub(r"[^a-z0-9-]", "-", slug).strip("-") or uuid.uuid4().hex[:8]
return f"{SANDBOX_DOMAIN_PREFIX}{slug}{SANDBOX_ALLOWED_SUFFIX}"
def _vm112_onboarding_headers(session_id: str) -> dict[str, str]:
return {"X-Onboarding-Session": session_id, "Content-Type": "application/json"}
def _extract_account_emails(domain_data: dict[str, Any]) -> list[str]:
emails: list[str] = []
for key in ("accounts", "accounts_preview", "emails"):
raw = domain_data.get(key)
if isinstance(raw, list):
for item in raw:
if isinstance(item, str) and "@" in item:
emails.append(item.lower())
elif isinstance(item, dict):
e = item.get("email") or item.get("name")
if e and "@" in str(e):
emails.append(str(e).lower())
admin = domain_data.get("admin_email") or domain_data.get("portal_admin")
if admin and "@" in str(admin):
emails.append(str(admin).lower())
return sorted(set(emails))
def fetch_domain_readonly(domain: str) -> dict[str, Any]:
domain = domain.lower().strip()
try:
data = vm112_domains.get_domain(domain)
return {"ok": True, "source": "vm112", "domain": domain, "data": data}
except httpx.HTTPError as exc:
return {
"ok": False,
"source": "vm112",
"domain": domain,
"error": str(exc),
"data": None,
}
def list_domains_readonly(q: str = "") -> dict[str, Any]:
try:
data = vm112_domains.list_domains(q)
return {"ok": True, "source": "vm112", "data": data}
except httpx.HTTPError as exc:
return {"ok": False, "source": "vm112", "error": str(exc), "data": None}
def _create_account_vm112(
*,
domain: str,
local_part: str,
password: str,
display_name: str,
) -> dict[str, Any]:
session_id = f"sandbox-{uuid.uuid4().hex}"
body = {
"domain": domain,
"local_part": local_part,
"password": password,
"display_name": display_name,
"use_server_password": False,
"send_welcome": False,
"dns_mode": "sandbox",
}
with httpx.Client(timeout=180.0) as client:
r = client.post(
f"{VM112_API}/api/onboarding/account/create",
json=body,
headers=_vm112_onboarding_headers(session_id),
)
if r.status_code >= 400:
try:
detail = r.json()
except Exception:
detail = r.text
raise SandboxError(f"VM112 account/create HTTP {r.status_code}: {detail}")
return r.json()
def create_scenario(
conn: sqlite3.Connection,
*,
label: str,
domain: str | None,
slug: str | None,
created_by: str,
plan_mock: str = "Business",
admin_password: str | None = None,
) -> dict[str, Any]:
assert_enabled()
domain = (domain or suggest_scenario_domain(slug)).lower().strip()
if domain in PROTECTED_DOMAINS:
raise SandboxForbidden(f"Domínio protegido: {domain}")
if not is_scenario_domain(domain):
raise SandboxForbidden(
f"Sandbox só cria domínios novos com prefixo «{SANDBOX_DOMAIN_PREFIX}» "
f"e sufixo «{SANDBOX_ALLOWED_SUFFIX}». Sugestão: {suggest_scenario_domain(slug)}"
)
existing = fetch_domain_readonly(domain)
baseline: list[str] = []
if existing.get("ok") and existing.get("data"):
baseline = _extract_account_emails(existing["data"])
if baseline:
raise SandboxForbidden(
f"Domínio {domain} já tem contas em produção — sandbox não altera existentes. "
"Use outro nome de cenário."
)
password = admin_password or secrets.token_urlsafe(12)
if len(password) < 8:
raise SandboxError("Senha admin mínimo 8 caracteres")
vm112_result = _create_account_vm112(
domain=domain,
local_part="admin",
password=password,
display_name=f"Gerente {label or domain}",
)
scenario = domain_console_sandbox_store.create_scenario(
conn,
domain=domain,
label=label or domain,
baseline_accounts=baseline,
created_by=created_by,
plan_mock=plan_mock,
)
admin_email = f"admin@{domain}"
return {
"scenario": scenario,
"vm112": vm112_result,
"credentials": {
"admin_email": admin_email,
"password": password,
"webmail": f"https://mail.{domain}/",
"console": "https://onboard.ligbox.com.br/admin",
},
"guards": {
"delete_blocked": True,
"baseline_protected": baseline,
"mode": "create-only",
},
}
def add_account_to_scenario(
conn: sqlite3.Connection,
*,
scenario_id: str,
local_part: str,
display_name: str,
password: str | None = None,
) -> dict[str, Any]:
assert_enabled()
scenario = domain_console_sandbox_store.get_scenario(conn, scenario_id)
if not scenario:
raise SandboxError("Cenário não encontrado")
domain = scenario["domain"]
local_part = re.sub(r"[^a-z0-9._+-]", "", local_part.lower().strip())
if not local_part:
raise SandboxError("local_part inválido")
email = f"{local_part}@{domain}"
baseline = set(scenario["baseline_accounts"])
if email in baseline:
raise SandboxForbidden(
f"{email} já existia antes do cenário — sandbox não sobrescreve contas existentes"
)
if email in scenario["created_accounts"]:
raise SandboxError(f"{email} já foi criada neste cenário")
pwd = password or secrets.token_urlsafe(10)
if len(pwd) < 8:
raise SandboxError("Senha mínimo 8 caracteres")
vm112_result = _create_account_vm112(
domain=domain,
local_part=local_part,
password=pwd,
display_name=display_name or local_part,
)
updated = domain_console_sandbox_store.append_created_account(conn, scenario_id, email)
return {
"scenario": updated,
"account": {
"email": email,
"password": pwd,
"display_name": display_name or local_part,
},
"vm112": vm112_result,
"guards": {"delete_blocked": True, "mode": "create-only"},
}
def build_console_state(conn: sqlite3.Connection, scenario_id: str) -> dict[str, Any]:
"""Estado UI — merge VM112 read + cenário local."""
scenario = domain_console_sandbox_store.get_scenario(conn, scenario_id)
if not scenario:
raise SandboxError("Cenário não encontrado")
domain = scenario["domain"]
live = fetch_domain_readonly(domain)
live_emails = _extract_account_emails(live["data"]) if live.get("data") else []
accounts = []
seen = set()
for email in live_emails or scenario["created_accounts"]:
if email in seen:
continue
seen.add(email)
is_baseline = email in scenario["baseline_accounts"]
is_created = email in scenario["created_accounts"]
accounts.append(
{
"email": email,
"name": email.split("@")[0].title(),
"mail_gb": 30,
"files_gb": 200,
"nc": True,
"active": True,
"protected": is_baseline,
"sandbox_created": is_created,
"can_delete": False,
}
)
return {
"mode": "live-sandbox",
"sandbox": True,
"delete_blocked": True,
"scenario": scenario,
"domain": domain,
"plan": {
"name": scenario["plan_mock"],
"price": 549,
"maxSeats": 25,
"mailGbDefault": 30,
"filesGbDefault": 200,
},
"billing": {"status": "Sandbox", "nextRenewal": ""},
"dmarc": {"spf": True, "dkim": True, "dmarc": True, "score": 95},
"dns": {
"mx": live.get("ok", False),
"aMail": live.get("ok", False),
"subdomain": f"intranet.{domain}",
},
"accounts": accounts,
"vm112_live": live,
}

View file

@ -0,0 +1,175 @@
"""API sandbox — Área Gerente create-only (Spec 035)."""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app import auth, domain_console_sandbox as sandbox
from app.permissions import can_manage_vm112_domains
router = APIRouter(prefix="/api/v1/domain-console/sandbox", tags=["domain-console-sandbox"])
def _require_sandbox(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
if not can_manage_vm112_domains(user.role):
raise HTTPException(403, "Sem permissão sandbox domain console")
return user
def _db() -> sqlite3.Connection:
from app.main import db
return db()
class CreateScenarioBody(BaseModel):
label: str = Field(..., min_length=2, max_length=120)
domain: str | None = None
slug: str | None = Field(None, max_length=40)
plan_mock: str = "Business"
admin_password: str | None = Field(None, min_length=8)
class AddAccountBody(BaseModel):
local_part: str = Field(..., min_length=1, max_length=64)
display_name: str = Field("", max_length=120)
password: str | None = Field(None, min_length=8)
@router.get("/config")
def sandbox_config(_user: auth.DeskUser = Depends(_require_sandbox)):
return {
"enabled": sandbox.SANDBOX_ENABLED,
"mode": "create-only",
"delete_blocked": True,
"domain_prefix": sandbox.SANDBOX_DOMAIN_PREFIX,
"domain_suffix": sandbox.SANDBOX_ALLOWED_SUFFIX,
"suggested_domain": sandbox.suggest_scenario_domain(),
"protected_domains": sorted(sandbox.PROTECTED_DOMAINS),
}
@router.get("/domains")
def list_domains_readonly(
q: str = "",
_user: auth.DeskUser = Depends(_require_sandbox),
):
return sandbox.list_domains_readonly(q)
@router.get("/domains/{domain}")
def get_domain_readonly(
domain: str,
_user: auth.DeskUser = Depends(_require_sandbox),
):
return sandbox.fetch_domain_readonly(domain)
@router.get("/scenarios")
def list_scenarios(
user: auth.DeskUser = Depends(_require_sandbox),
):
from app.domain_console_sandbox_store import init_schema, list_scenarios as ls
conn = _db()
init_schema(conn)
return {"scenarios": ls(conn), "delete_blocked": True}
@router.post("/scenarios")
def create_scenario(
body: CreateScenarioBody,
user: auth.DeskUser = Depends(_require_sandbox),
):
from app.domain_console_sandbox_store import init_schema
conn = _db()
init_schema(conn)
try:
return sandbox.create_scenario(
conn,
label=body.label,
domain=body.domain,
slug=body.slug,
created_by=user.username,
plan_mock=body.plan_mock,
admin_password=body.admin_password,
)
except sandbox.SandboxForbidden as e:
raise HTTPException(403, str(e)) from e
except sandbox.SandboxError as e:
raise HTTPException(400, str(e)) from e
@router.get("/scenarios/{scenario_id}/state")
def scenario_state(
scenario_id: str,
_user: auth.DeskUser = Depends(_require_sandbox),
):
from app.domain_console_sandbox_store import init_schema
conn = _db()
init_schema(conn)
try:
return sandbox.build_console_state(conn, scenario_id)
except sandbox.SandboxError as e:
raise HTTPException(404, str(e)) from e
@router.post("/scenarios/{scenario_id}/accounts")
def add_account(
scenario_id: str,
body: AddAccountBody,
_user: auth.DeskUser = Depends(_require_sandbox),
):
from app.domain_console_sandbox_store import init_schema
conn = _db()
init_schema(conn)
try:
return sandbox.add_account_to_scenario(
conn,
scenario_id=scenario_id,
local_part=body.local_part,
display_name=body.display_name,
password=body.password,
)
except sandbox.SandboxForbidden as e:
raise HTTPException(403, str(e)) from e
except sandbox.SandboxError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/scenarios/{scenario_id}")
def block_delete_scenario(scenario_id: str, _user: auth.DeskUser = Depends(_require_sandbox)):
try:
sandbox.assert_no_delete(f"delete scenario {scenario_id}")
except sandbox.SandboxForbidden as e:
raise HTTPException(403, str(e)) from e
raise HTTPException(403, "delete bloqueado")
@router.delete("/scenarios/{scenario_id}/accounts/{email}")
def block_delete_account(
scenario_id: str,
email: str,
_user: auth.DeskUser = Depends(_require_sandbox),
):
try:
sandbox.assert_no_delete(f"delete account {email}")
except sandbox.SandboxForbidden as e:
raise HTTPException(403, str(e)) from e
raise HTTPException(403, "delete bloqueado")
@router.post("/scenarios/{scenario_id}/purge")
def block_purge_scenario(scenario_id: str, _user: auth.DeskUser = Depends(_require_sandbox)):
try:
sandbox.assert_no_delete(f"purge scenario {scenario_id}")
except sandbox.SandboxForbidden as e:
raise HTTPException(403, str(e)) from e
raise HTTPException(403, "purge bloqueado")

View file

@ -0,0 +1,134 @@
"""Persistência cenários sandbox — Spec 035 (create-only audit trail)."""
from __future__ import annotations
import json
import sqlite3
import uuid
from datetime import datetime, timezone
from typing import Any
def _ts() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS domain_console_scenarios (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
plan_mock TEXT NOT NULL DEFAULT 'Business',
baseline_accounts_json TEXT NOT NULL DEFAULT '[]',
created_accounts_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dcs_domain ON domain_console_scenarios(domain);
"""
)
conn.commit()
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
d = dict(row)
d["baseline_accounts"] = json.loads(d.pop("baseline_accounts_json") or "[]")
d["created_accounts"] = json.loads(d.pop("created_accounts_json") or "[]")
return d
def list_scenarios(conn: sqlite3.Connection, limit: int = 50) -> list[dict[str, Any]]:
rows = conn.execute(
"""
SELECT * FROM domain_console_scenarios
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [_row_to_dict(r) for r in rows]
def get_scenario(conn: sqlite3.Connection, scenario_id: str) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM domain_console_scenarios WHERE id = ?", (scenario_id,)
).fetchone()
return _row_to_dict(row) if row else None
def get_scenario_by_domain(conn: sqlite3.Connection, domain: str) -> dict[str, Any] | None:
row = conn.execute(
"""
SELECT * FROM domain_console_scenarios
WHERE domain = ? AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""",
(domain.lower().strip(),),
).fetchone()
return _row_to_dict(row) if row else None
def create_scenario(
conn: sqlite3.Connection,
*,
domain: str,
label: str,
baseline_accounts: list[str],
created_by: str,
plan_mock: str = "Business",
) -> dict[str, Any]:
scenario_id = str(uuid.uuid4())
now = _ts()
admin_email = f"admin@{domain}"
created = [admin_email] if admin_email not in baseline_accounts else []
conn.execute(
"""
INSERT INTO domain_console_scenarios (
id, domain, label, plan_mock, baseline_accounts_json,
created_accounts_json, status, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(
scenario_id,
domain.lower().strip(),
label,
plan_mock,
json.dumps(sorted(set(baseline_accounts)), ensure_ascii=False),
json.dumps(created, ensure_ascii=False),
created_by,
now,
now,
),
)
conn.commit()
out = get_scenario(conn, scenario_id)
assert out is not None
return out
def append_created_account(conn: sqlite3.Connection, scenario_id: str, email: str) -> dict[str, Any]:
row = get_scenario(conn, scenario_id)
if not row:
raise ValueError("cenário não encontrado")
created = list(row["created_accounts"])
email = email.lower().strip()
if email not in created:
created.append(email)
now = _ts()
conn.execute(
"""
UPDATE domain_console_scenarios
SET created_accounts_json = ?, updated_at = ?
WHERE id = ?
""",
(json.dumps(created, ensure_ascii=False), now, scenario_id),
)
conn.commit()
out = get_scenario(conn, scenario_id)
assert out is not None
return out

View file

@ -19,6 +19,7 @@ from app.assist_routes import router as assist_router, process_escalation_webhoo
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.dns_viewer import fetch_dns_viewer
from app.modules.routes import router as modules_router
from app.vm112_domains_routes import router as vm112_domains_router
from app.purge_auth_routes import router as purge_auth_router
@ -30,8 +31,11 @@ from app.infra_stack_routes import router as infra_stack_router
from app.vm123.routes import router as vm123_router
from app.agents.routes import router as agents_router
from app.rbac_routes import router as rbac_router
from app.domain_console_sandbox_routes import router as domain_console_sandbox_router
from app.domain_console_routes import router as domain_console_router
from app.agents.store import init_agent_schema
from app.collectors.base import run_audit
from app.openpanel_dns import fetch_openpanel_bind_records
from app.permissions import (
can_assign_ticket,
can_list_webhook_events,
@ -39,6 +43,7 @@ from app.permissions import (
can_read_audit_overview,
can_read_audit_scorecard,
can_read_cloudflare_dns,
can_read_dns_viewer,
can_read_funnel,
can_read_session_timeline,
can_read_tickets,
@ -49,6 +54,7 @@ from app.permissions import (
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")
DNS_VIEWER_ENABLED = os.getenv("DNS_VIEWER_ENABLED", "1").lower() in ("1", "true", "yes")
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")
@ -138,6 +144,8 @@ app.include_router(infra_stack_router)
app.include_router(vm123_router)
app.include_router(agents_router)
app.include_router(rbac_router)
app.include_router(domain_console_sandbox_router)
app.include_router(domain_console_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"
@ -194,6 +202,9 @@ def init_db():
from app import agent_bindings
agent_bindings.init_schema(conn)
from app.domain_console_sandbox_store import init_schema as init_dcs_schema
init_dcs_schema(conn)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=60000")
conn.commit()
@ -1149,6 +1160,38 @@ async def cloudflare_dns_records(
return await fetch_domain_dns(domain, email_service=email_service)
@app.get("/api/v1/dns/viewer/{domain}")
async def dns_viewer(
domain: str,
email_service: bool | None = Query(default=None),
include_public: bool = Query(default=True),
user: auth.DeskUser = Depends(auth.require_internal_or_user),
):
if not DNS_VIEWER_ENABLED:
raise HTTPException(404, "DNS Viewer disabled")
if not can_read_dns_viewer(user.role):
raise HTTPException(403, "insufficient permissions")
domain = domain.lower().strip().rstrip(".")
if len(domain) < 3:
raise HTTPException(400, "domain too short")
return await fetch_dns_viewer(
domain,
role=user.role,
email_service=email_service,
include_public=include_public,
)
@app.get("/api/v1/dns/openpanel/records")
async def openpanel_dns_records(
domain: str = Query(..., min_length=3),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_dns_viewer(user.role):
raise HTTPException(403, "insufficient permissions")
return fetch_openpanel_bind_records(domain.lower().strip().rstrip("."))
@app.get("/api/v1/audit/tenants/{tenant_id}/scorecard")
def audit_scorecard(
tenant_id: int,

View file

@ -0,0 +1,138 @@
"""OpenPanel BIND DNS records (read-only) — Spec 037 V2."""
from __future__ import annotations
import os
import re
from typing import Any
from app.cloudflare_dns import _classify_record, _record_belongs
from app.collectors.dns import _dig
OPENPANEL_BIND_HOST = os.getenv("OPENPANEL_BIND_HOST", "10.10.10.123")
OPENPANEL_PUBLIC_IP = os.getenv("OPENPANEL_PUBLIC_DNS_IP", "95.216.14.162")
OPENPANEL_URL = os.getenv("OPENPANEL_URL", "https://openpanel.ligbox.com.br").rstrip("/")
# Ligbox authoritative BIND — common public NS hostnames
OPENPANEL_NS_HINTS = ("openpanel", "ligbox", "itecnologys")
def _dig_bind(name: str, rtype: str) -> list[str]:
host = OPENPANEL_BIND_HOST.strip()
if not host:
return []
try:
import subprocess
proc = subprocess.run(
["dig", f"@{host}", "+short", name, rtype],
capture_output=True,
text=True,
timeout=8,
)
if proc.returncode == 0 and proc.stdout.strip():
return [ln.strip().strip('"') for ln in proc.stdout.splitlines() if ln.strip()]
except Exception:
pass
return []
def _parse_mx(line: str) -> tuple[int | None, str]:
m = re.match(r"(\d+)\s+(.+)", line.strip())
if m:
return int(m.group(1)), m.group(2).rstrip(".")
return None, line.strip().rstrip(".")
def _names_to_query(domain: str) -> list[str]:
domain = domain.lower().strip().rstrip(".")
names = {domain, f"mail.{domain}", f"www.{domain}", f"_dmarc.{domain}", f"default._domainkey.{domain}"}
return sorted(names)
def ns_points_to_openpanel(domain: str) -> bool:
for ns in _dig(domain, "NS"):
ns_l = ns.lower().rstrip(".")
if any(h in ns_l for h in OPENPANEL_NS_HINTS):
return True
for a in _dig(ns_l, "A"):
if a == OPENPANEL_PUBLIC_IP or a == OPENPANEL_BIND_HOST:
return True
return False
def bind_zone_responds(domain: str) -> bool:
host = OPENPANEL_BIND_HOST
if not host:
return False
try:
import subprocess
proc = subprocess.run(
["dig", f"@{host}", "+norecurse", domain, "SOA", "+short"],
capture_output=True,
text=True,
timeout=8,
)
return proc.returncode == 0 and bool(proc.stdout.strip())
except Exception:
return False
def fetch_openpanel_bind_records(domain: str) -> dict[str, Any]:
"""List records from OpenPanel BIND via dig @ bind host (read-only)."""
domain = domain.lower().strip().rstrip(".")
rows: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
def add(name: str, rtype: str, content: str, priority: int | None = None) -> None:
name = name.rstrip(".").lower()
content = content.strip().strip('"')
key = (name, rtype, content)
if not content or key in seen:
return
if not _record_belongs(name, domain):
return
seen.add(key)
purpose = _classify_record(name, rtype, content)
rows.append(
{
"source": "openpanel_bind",
"status": "applied",
"type": rtype,
"name": name,
"content": content,
"priority": priority,
"ttl": None,
"purpose": purpose,
"email_related": purpose
in ("mx", "spf", "dkim", "dmarc", "mail-host", "autodiscover", "mail-alias"),
}
)
for name in _names_to_query(domain):
for rtype in ("A", "AAAA", "CNAME", "TXT"):
for line in _dig_bind(name, rtype):
if rtype == "CNAME" and line.endswith("."):
line = line.rstrip(".")
add(name, rtype, line)
for line in _dig_bind(name, "MX"):
prio, target = _parse_mx(line)
add(name, "MX", target, priority=prio)
rows.sort(key=lambda r: (0 if r["email_related"] else 1, r["type"], r["name"]))
return {
"domain": domain,
"bind_host": OPENPANEL_BIND_HOST,
"zone_responds": bind_zone_responds(domain),
"ns_on_openpanel": ns_points_to_openpanel(domain),
"records": rows,
"summary": {
"total": len(rows),
"email_related": sum(1 for r in rows if r.get("email_related")),
},
"edit_url": f"{OPENPANEL_URL}/domains/{domain}/dns",
"error": None if rows or bind_zone_responds(domain) else "Zona não encontrada no BIND OpenPanel",
}

View file

@ -124,6 +124,19 @@ def can_read_cloudflare_dns(role: str) -> bool:
)
def can_read_dns_viewer(role: str) -> bool:
"""Spec 037-DNS-VIEWER — same read surface as Cloudflare DNS for V1."""
return can_read_cloudflare_dns(role)
def can_open_cloudflare_dns_link(role: str) -> bool:
return role in ("super_admin", "ops_lead", "devops", "seo")
def can_open_openpanel_link(role: str) -> bool:
return role in ("super_admin", "ops_lead", "sales_admin", "sales_support", "seo", "devops")
def can_read_funnel(role: str) -> bool:
return role in (
"super_admin",

View file

@ -900,6 +900,9 @@ function dnsPurposeLabel(purpose) {
}
async function fetchCloudflareDns(domain, emailService) {
if (window.DnsViewer?.fetchDomainDns) {
return window.DnsViewer.fetchDomainDns(domain, emailService);
}
try {
return await api(
`/v1/dns/cloudflare/records?domain=${encodeURIComponent(domain)}&email_service=${emailService ? 'true' : 'false'}`
@ -942,7 +945,7 @@ async function showOverviewHomeDnsPanel(domain, tenantId, funnelStage, domainMet
const timingCard = phaseTimingCardHtml(timing, timeline);
const dns = await fetchCloudflareDns(domain, isEmailServiceDomain(tenantId, funnelStage));
panel.innerHTML = `${timingCard}${htmlCloudflareDnsCardInline(dns)}`;
panel.innerHTML = `${timingCard}${window.DnsViewer?.renderPanel ? window.DnsViewer.renderPanel(dns, { compact: true }) : htmlCloudflareDnsCardInline(dns)}`;
}
function htmlCloudflareDnsCardInline(dns) {
@ -1651,7 +1654,7 @@ async function openOverviewDomainDetail(domain) {
<div><dt>Ticket</dt><dd>${d.ticket_id ? `#${d.ticket_id} (${esc(d.ticket_status || '')})` : ''}</dd></div>
</dl>
${ips.length > 1 ? `<p class="ticket-meta">IPs observados: ${ips.map((ip) => `<code>${esc(ip)}</code>`).join(' · ')}</p>` : ''}
${htmlCloudflareDnsCard(dnsData)}
${window.DnsViewer?.renderPanel ? window.DnsViewer.renderPanel(dnsData) : htmlCloudflareDnsCard(dnsData)}
<div class="modal-section">
<h4>Checks de auditoria</h4>
<div class="table-wrap">

View file

@ -0,0 +1,204 @@
/**
* Spec 037-DNS-VIEWER painel DNS unificado (Desk)
* Depende de: auth.js (api, esc)
*/
(function () {
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 || '—';
}
function sourceLabel(source) {
return {
cf_ligbox: 'CF Ligbox',
cf_byo: 'CF cliente',
public_resolver: 'Público',
planned_ligbox: 'Planeado',
openpanel_bind: 'OpenPanel',
}[source] || source || '—';
}
function checkIcon(c) {
if (!c) return '—';
if (c.ok) return '✅';
if (c.warn) return '⚠';
return '❌';
}
async function fetchDnsViewer(domain, emailService) {
const q = new URLSearchParams();
if (emailService != null) q.set('email_service', emailService ? 'true' : 'false');
const qs = q.toString();
return api(`/v1/dns/viewer/${encodeURIComponent(domain)}${qs ? `?${qs}` : ''}`);
}
async function fetchDomainDns(domain, emailService) {
try {
const viewer = await fetchDnsViewer(domain, emailService);
if (viewer && viewer.domain) return { ...viewer, _viewer: true };
} catch (e) {
if (e?.status === 404) {
/* DNS_VIEWER_ENABLED=0 — legado */
} else if (e?.status !== 403) {
console.warn('DnsViewer fallback:', e?.message || e);
}
}
try {
const legacy = await api(
`/v1/dns/cloudflare/records?domain=${encodeURIComponent(domain)}&email_service=${emailService ? 'true' : 'false'}`
);
return legacy;
} catch (e) {
return {
domain,
records: [],
email_records: [],
summary: { total: 0, email_related: 0 },
error: e.message || 'Falha ao carregar DNS',
};
}
}
function publicChecksRow(checks) {
if (!checks || !Object.keys(checks).length) return '';
const parts = ['mx', 'spf', 'dkim', 'dmarc'].map(
(k) => `${k.toUpperCase()} ${checkIcon(checks[k])}`
);
return `<p class="cf-dns-meta dns-viewer-checks">Verificação pública: ${parts.join(' · ')}</p>`;
}
function nsRow(data) {
const ns = data.nameservers || {};
const current = ns.current_public || [];
if (!current.length) return '';
const match = ns.match_ligbox;
const warn = data.display_mode === 'planned' || (data.dns_mode?.startsWith('ligbox_cf') && !match);
return `<p class="cf-dns-meta ${warn ? 'dns-viewer-ns-warn' : ''}">NS actuais: <code>${current.map((n) => esc(n)).join('</code> · <code>')}</code>${warn ? ' · ⚠ delegação Ligbox pendente' : match ? ' · ✅ Cloudflare' : ''}</p>`;
}
function recordRows(records, compact) {
return (records || []).map((r) => {
const src = r.source ? `<span class="dns-source-badge">${esc(sourceLabel(r.source))}</span>` : '';
const state = r.status === 'planned' ? ' <span class="badge review">planeado</span>' : '';
if (compact) {
return `
<tr class="${r.email_related ? 'dns-email-row' : ''}">
<td><span class="dns-purpose-badge purpose-${esc(r.purpose || 'other')}">${esc(dnsPurposeLabel(r.purpose))}</span></td>
<td><code>${esc(r.name)}</code></td>
<td><strong>${esc(r.type)}</strong></td>
<td class="dns-content">${esc(r.content)}</td>
</tr>`;
}
return `
<tr class="${r.email_related ? 'dns-email-row' : ''}">
<td><span class="dns-purpose-badge purpose-${esc(r.purpose || 'other')}">${esc(dnsPurposeLabel(r.purpose))}</span></td>
<td><code>${esc(r.name)}</code></td>
<td><strong>${esc(r.type)}</strong>${r.priority != null ? ` <span class="ticket-meta">prio ${r.priority}</span>` : ''}</td>
<td class="dns-content">${esc(r.content)}</td>
<td class="ticket-meta">${src}${state}${r.proxied != null ? (r.proxied ? ' · proxy' : ' · DNS only') : ''}</td>
</tr>`;
}).join('');
}
function editLinksHtml(links) {
if (!links?.length) return '';
return `<div class="dns-viewer-actions">${links.map((l) => `<a class="btn btn-ghost btn-sm" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label)} ↗</a>`).join(' ')}</div>`;
}
function renderDnsPanel(data, { compact = false, title = 'DNS do domínio' } = {}) {
if (!data) {
return compact
? '<p class="cf-dns-empty">Dados DNS indisponíveis.</p>'
: `<div class="modal-section"><h4>${esc(title)}</h4><p class="loading">Dados DNS indisponíveis.</p></div>`;
}
const isViewer = data._viewer || data.dns_mode;
if (!isViewer) {
return compact ? legacyInline(data) : legacyCard(data, title);
}
const summary = data.summary || {};
const zone = data.zone || {};
const rows = recordRows(data.records, compact);
const planned = (data.planned_records || []).length
? `<h5 class="dns-viewer-sub">Planeados (após NS)</h5><div class="cf-dns-table-wrap"><table class="data-table dns-records-table ${compact ? 'dns-records-table-compact' : ''}"><thead><tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th>${compact ? '' : '<th>Origem</th>'}</tr></thead><tbody>${recordRows(data.planned_records, compact) || '<tr><td colspan="5">—</td></tr>'}</tbody></table></div>`
: '';
const badgeCls = data.display_mode === 'planned' ? 'review' : data.dns_mode?.startsWith('ligbox_cf') ? 'onboard' : 'open';
const inner = `
<div class="dns-viewer-head">
<span class="badge ${badgeCls}">${esc(data.mode_label || data.dns_mode || 'DNS')}</span>
${data.email_service ? '<span class="badge onboard">E-mail</span>' : ''}
</div>
${data.mode_message ? `<p class="cf-dns-meta dns-viewer-msg">${esc(data.mode_message)}</p>` : ''}
${nsRow(data)}
<div class="cf-dns-inline-summary">
<div class="cf-metric-stat"><strong>${summary.total || 0}</strong><span>registos</span></div>
<div class="cf-metric-stat"><strong>${summary.email_related || 0}</strong><span>e-mail</span></div>
${summary.planned ? `<div class="cf-metric-stat"><strong>${summary.planned}</strong><span>planeados</span></div>` : ''}
</div>
${zone.name ? `<p class="cf-dns-meta">Zona CF <code>${esc(zone.name)}</code></p>` : ''}
${publicChecksRow(data.public_checks)}
<div class="cf-dns-table-wrap">
<table class="data-table dns-records-table ${compact ? 'dns-records-table-compact' : ''}">
<thead><tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th>${compact ? '' : '<th>Origem</th>'}</tr></thead>
<tbody>${rows || '<tr><td colspan="5">Sem registos para este domínio.</td></tr>'}</tbody>
</table>
</div>
${planned}
${editLinksHtml(data.edit_links)}
${data.errors?.length ? `<p class="cf-dns-error">${esc(data.errors.join(' · '))}</p>` : ''}`;
if (compact) return inner;
return `
<div class="modal-section dns-records-section dns-viewer-section">
<div class="card-head-row">
<h4>${esc(title)}</h4>
</div>
${inner}
</div>`;
}
function legacyInline(dns) {
if (dns.error && !dns.records?.length) {
return `<p class="cf-dns-error">${esc(dns.error)}</p>`;
}
const rows = recordRows(dns.records, true);
const summary = dns.summary || {};
return `
<div class="cf-dns-inline-summary">
<div class="cf-metric-stat"><strong>${summary.total || 0}</strong><span>registos na zona</span></div>
<div class="cf-metric-stat"><strong>${summary.email_related || 0}</strong><span>para e-mail</span></div>
</div>
<div class="cf-dns-table-wrap">
<table class="data-table dns-records-table dns-records-table-compact">
<thead><tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">Sem registos.</td></tr>'}</tbody>
</table>
</div>`;
}
function legacyCard(dns, title) {
return `
<div class="modal-section dns-records-section">
<h4>${esc(title)} (Cloudflare)</h4>
${legacyInline(dns)}
</div>`;
}
window.DnsViewer = {
fetch: fetchDnsViewer,
fetchDomainDns,
renderPanel: renderDnsPanel,
dnsPurposeLabel,
};
})();

View file

@ -3,14 +3,17 @@ import ConsoleHome from './views/ConsoleHome'
import Discover from './views/Discover'
import ChamadosList from './views/ChamadosList'
import ChamadoHub from './views/ChamadoHub'
import AdminShell from './layouts/AdminShell'
import AdminHome from './views/admin/AdminHome'
import AdminDominio from './views/admin/AdminDominio'
const nav = [
const opsNav = [
{ to: '/', label: 'Console', end: true, icon: '◆' },
{ to: '/discover', label: 'Discover', icon: '◇' },
{ to: '/chamados', label: 'Chamados', icon: '◇' },
]
export default function App() {
function OpsShell() {
return (
<div className="shell">
<aside className="sidebar">
@ -23,7 +26,7 @@ export default function App() {
</div>
<p className="nav-section-label">Navegação</p>
<nav>
{nav.map((item) => (
{opsNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
@ -34,6 +37,10 @@ export default function App() {
</NavLink>
))}
</nav>
<p className="nav-section-label">Gerente</p>
<nav>
<NavLink to="/admin" className="nav-link">Área Admin </NavLink>
</nav>
<p className="status-pill" style={{ marginTop: '2rem' }}>
API: {import.meta.env.VITE_API_URL || 'api.ops.ligbox.com.br'}
</p>
@ -49,3 +56,15 @@ export default function App() {
</div>
)
}
export default function App() {
return (
<Routes>
<Route path="/admin/*" element={<AdminShell />}>
<Route index element={<AdminHome />} />
<Route path="dominio" element={<AdminDominio />} />
</Route>
<Route path="/*" element={<OpsShell />} />
</Routes>
)
}

View file

@ -0,0 +1,132 @@
const PURPOSE = {
mx: 'MX',
spf: 'SPF',
dkim: 'DKIM',
dmarc: 'DMARC',
'mail-host': 'Mail host',
autodiscover: 'Autodiscover',
'mail-alias': 'Alias',
other: 'Outro',
}
const SOURCE = {
cf_ligbox: 'CF Ligbox',
cf_byo: 'CF cliente',
public_resolver: 'Público',
planned_ligbox: 'Planeado',
openpanel_bind: 'OpenPanel',
}
function checkIcon(c) {
if (!c) return '—'
if (c.ok) return '✅'
if (c.warn) return '⚠'
return '❌'
}
export default function DnsViewerPanel({ data, loading, error }) {
if (loading) return <p className="loading">Carregando DNS</p>
if (error) return <p className="error-text">{error}</p>
if (!data) return <p className="loading">Sem dados DNS.</p>
const summary = data.summary || {}
const checks = data.public_checks || {}
const ns = data.nameservers || {}
return (
<div className="dns-viewer-panel card">
<div className="dns-viewer-head">
<span className={`badge ${data.display_mode === 'planned' ? 'badge-warn' : 'badge-ok'}`}>
{data.mode_label || data.dns_mode}
</span>
{data.email_service ? <span className="badge badge-ok">E-mail</span> : null}
</div>
{data.mode_message ? <p className="dns-viewer-msg">{data.mode_message}</p> : null}
{ns.current_public?.length ? (
<p className="dns-viewer-meta">
NS: {ns.current_public.join(' · ')}
{ns.match_ligbox ? ' · ✅ Cloudflare' : data.dns_mode === 'openpanel_bind' ? ' · OpenPanel BIND' : ''}
</p>
) : null}
{data.openpanel?.zone_responds ? (
<p className="dns-viewer-meta">
BIND OpenPanel ({data.openpanel.bind_host}) zona activa
</p>
) : null}
<div className="kpi-inline">
<span><strong>{summary.total || 0}</strong> registos</span>
<span><strong>{summary.email_related || 0}</strong> e-mail</span>
</div>
{Object.keys(checks).length ? (
<p className="dns-viewer-meta">
Público: MX {checkIcon(checks.mx)} · SPF {checkIcon(checks.spf)} · DKIM {checkIcon(checks.dkim)} · DMARC {checkIcon(checks.dmarc)}
</p>
) : null}
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th><th>Origem</th>
</tr>
</thead>
<tbody>
{(data.records || []).length ? (data.records || []).map((r, i) => (
<tr key={`${r.name}-${r.type}-${i}`} className={r.email_related ? 'dns-email-row' : ''}>
<td>{PURPOSE[r.purpose] || r.purpose || '—'}</td>
<td><code>{r.name}</code></td>
<td><strong>{r.type}</strong>{r.priority != null ? ` (${r.priority})` : ''}</td>
<td className="dns-content">{r.content}</td>
<td>{SOURCE[r.source] || r.source || '—'}</td>
</tr>
)) : (
<tr><td colSpan={5}>Sem registos para este domínio.</td></tr>
)}
</tbody>
</table>
</div>
{(data.planned_records || []).length ? (
<>
<h4 className="dns-viewer-sub">Planeados (após NS)</h4>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr><th>Função</th><th>Nome</th><th>Tipo</th><th>Conteúdo</th></tr>
</thead>
<tbody>
{data.planned_records.map((r, i) => (
<tr key={`p-${i}`}>
<td>{PURPOSE[r.purpose] || '—'}</td>
<td><code>{r.name}</code></td>
<td>{r.type}</td>
<td>{r.content}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
{data.edit_links?.length ? (
<div className="dns-viewer-actions">
{data.edit_links.map((l) => (
<a key={l.url} className="btn btn-ghost btn-sm" href={l.url} target="_blank" rel="noopener noreferrer">
{l.label}
</a>
))}
</div>
) : null}
{data.errors?.length ? (
<p className="error-text">{data.errors.join(' · ')}</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,53 @@
import { NavLink, Outlet } from 'react-router-dom'
import { clearSession, getUser } from '../lib/auth'
const adminNav = [
{ to: '/admin', label: 'Início', end: true },
{ to: '/admin/dominio', label: 'Domínio & DNS' },
]
export default function AdminShell() {
const user = getUser()
return (
<div className="shell">
<aside className="sidebar sidebar-admin">
<div className="sidebar-brand">
<div className="sidebar-logo" aria-hidden="true">LB</div>
<div>
<h1>Ligbox Console</h1>
<p className="sidebar-sub">Gerente de domínio</p>
</div>
</div>
<p className="nav-section-label">Administração</p>
<nav>
{adminNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) => `nav-link${isActive ? ' active' : ''}`}
>
{item.label}
</NavLink>
))}
</nav>
<p className="nav-section-label">Staff</p>
<nav>
<NavLink to="/" className="nav-link"> Console Ops</NavLink>
</nav>
{user ? (
<p className="status-pill" style={{ marginTop: '1.5rem' }}>
{user.display_name || user.username}
</p>
) : null}
<button type="button" className="btn btn-ghost btn-sm" style={{ marginTop: '0.5rem' }} onClick={clearSession}>
Sair
</button>
</aside>
<main className="main">
<Outlet />
</main>
</div>
)
}

View file

@ -11,3 +11,23 @@ export async function apiGet(path, { token } = {}) {
}
return res.json()
}
export async function apiPost(path, body, { token } = {}) {
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.detail || data.message || `HTTP ${res.status}`)
err.status = res.status
throw err
}
return data
}
export { API_BASE }

View file

@ -0,0 +1,38 @@
const TOKEN_KEY = 'ligbox_console_token'
const USER_KEY = 'ligbox_console_user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function getUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY) || 'null')
} catch {
return null
}
}
export function setSession(token, user) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
export function isLoggedIn() {
return Boolean(getToken())
}
export async function loginDesk(username, password) {
const { apiPost } = await import('./api')
const data = await apiPost('/api/v1/auth/login', { username, password })
if (data.mfa_required) {
throw new Error('2FA necessário — use o Desk para login completo')
}
setSession(data.access_token, data.user)
return data
}

View file

@ -93,6 +93,27 @@ a:hover { text-decoration: underline; }
text-decoration: none;
}
.badge-warn { background: #fef3e8; color: #b5651d; }
.badge-ok { background: #e8f5ee; color: #2d6a4f; }
.dns-viewer-panel { margin-top: 0.5rem; }
.dns-viewer-head { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.5rem; }
.dns-viewer-msg { font-style: italic; color: var(--lb-text-secondary); font-size: 0.88rem; }
.dns-viewer-meta { font-size: 0.82rem; color: var(--lb-text-muted); margin: 0.35rem 0; }
.dns-viewer-sub { margin: 1rem 0 0.35rem; font-size: 0.9rem; }
.dns-viewer-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
.dns-content { word-break: break-all; max-width: 280px; font-size: 0.82rem; }
.dns-email-row { background: #f8fbff; }
.kpi-inline { display: flex; gap: 1.25rem; font-size: 0.85rem; margin: 0.5rem 0; }
.error-text { color: #c0392b; font-size: 0.88rem; }
.form-label { display: block; font-size: 0.82rem; margin-bottom: 0.75rem; }
.form-input {
display: block; width: 100%; margin-top: 0.25rem; padding: 0.45rem 0.6rem;
border: 1px solid var(--lb-border); border-radius: 6px; font: inherit;
}
.sidebar-admin { background: linear-gradient(180deg, #fff 0%, #f6f9fc 100%); }
.btn-sm { font-size: 0.82rem; padding: 0.35rem 0.65rem; }
.nav-link.active {
background: var(--lb-accent-soft);
color: var(--lb-accent);

View file

@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import DnsViewerPanel from '../../components/DnsViewerPanel'
import { apiGet } from '../../lib/api'
import { getToken, isLoggedIn, loginDesk } from '../../lib/auth'
export default function AdminDominio() {
const [params, setParams] = useSearchParams()
const [domain, setDomain] = useState(params.get('domain') || '')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const loadDns = useCallback(async (dom) => {
const d = (dom || '').trim().toLowerCase()
if (!d || d.length < 3) return
const token = getToken()
if (!token) {
setError('Faça login para ver o DNS.')
return
}
setLoading(true)
setError('')
try {
const payload = await apiGet(
`/api/v1/domain-console/dns/viewer/${encodeURIComponent(d)}?email_service=true`,
{ token },
)
setData(payload)
} catch (e) {
setData(null)
setError(e.message || 'Falha ao carregar DNS')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const q = params.get('domain')
if (q) {
setDomain(q)
if (isLoggedIn()) loadDns(q)
}
}, [params, loadDns])
async function handleLogin(e) {
e.preventDefault()
setLoginError('')
try {
await loginDesk(username, password)
if (domain) loadDns(domain)
} catch (err) {
setLoginError(err.message || 'Login falhou')
}
}
function handleSearch(e) {
e.preventDefault()
setParams(domain ? { domain } : {})
loadDns(domain)
}
return (
<div>
<h2 style={{ marginTop: 0 }}>Domínio & DNS</h2>
<p style={{ color: 'var(--lb-text-secondary)', maxWidth: 640 }}>
Apontamentos read-only Cloudflare Ligbox, OpenPanel BIND ou DNS público.
Alterações via suporte ou painel externo (links abaixo).
</p>
{!isLoggedIn() ? (
<form className="card login-card" onSubmit={handleLogin} style={{ maxWidth: 360, marginBottom: '1.25rem' }}>
<h3 style={{ marginTop: 0 }}>Login Desk</h3>
<p className="dns-viewer-meta">Use credenciais Ligbox Ops (staff) ou gerente.</p>
<label className="form-label">
Utilizador
<input className="form-input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label className="form-label">
Senha
<input type="password" className="form-input" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</label>
{loginError ? <p className="error-text">{loginError}</p> : null}
<button type="submit" className="btn btn-primary">Entrar</button>
</form>
) : null}
<form className="card domain-search" onSubmit={handleSearch} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: '1rem' }}>
<label className="form-label" style={{ flex: '1 1 220px', margin: 0 }}>
Domínio
<input
className="form-input"
placeholder="empresa.com.br"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Carregando…' : 'Consultar DNS'}
</button>
</form>
<DnsViewerPanel data={data} loading={loading} error={error} />
</div>
)
}

View file

@ -0,0 +1,17 @@
import { Link } from 'react-router-dom'
export default function AdminHome() {
return (
<div>
<h2 style={{ marginTop: 0 }}>Área Gerente de Domínio</h2>
<p style={{ color: 'var(--lb-text-secondary)' }}>
Cockpit Ligbox email, DNS e plano do seu domínio.
</p>
<div className="card" style={{ maxWidth: 480 }}>
<h3 style={{ marginTop: 0 }}>Começar</h3>
<p>Verifique os apontamentos DNS do seu domínio (read-only).</p>
<Link to="/admin/dominio" className="btn btn-primary">Domínio & DNS </Link>
</div>
</div>
)
}

View file

@ -0,0 +1,416 @@
# Spec 035-UI — Área Gerente de Domínio (Console único)
**Criado:** 2026-06-21
**Solicitado por:** Roger
**Status:** ✅ Decisões UX fixadas — ver [ligbox-console-shell.md](./ligbox-console-shell.md)
**URL canónica:** `https://console.ligbox.com.br/admin` (alias 301: `onboard.ligbox.com.br/admin`)
**Relacionado:** Spec **035** · **035-UX shell** · **010** · **034** · **024** · **037** ([dns-viewer.md](../037-dns-multi-cloudflare-orchestration/dns-viewer.md))
---
## 1. Decisão Roger (2026-06-21)
| Agora (Fase A) | Depois (Fase B — estudo separado) |
|----------------|-----------------------------------|
| **Área Gerente de Domínio** — uma página/app única | **Área por utilizador de email** — cada caixa gere as suas próprias coisas |
| Gerente configura bundle, contas, quotas, Nextcloud, DMARC | Redirects, out-of-office, férias, assinaturas, calendário pessoal |
| Login: `admin@{dominio}` ou SSO FOSS | Login: `{user}@{dominio}` — self-service limitado |
**Princípio:** o gerente **nunca** salta para FOSS, OpenPanel ou Nextcloud em modo «setup». Tudo converge num **cockpit Ligbox**.
---
## 2. Três portais Ligbox — mesmo princípio de agregação
Roger (2026-06-21): **três UIs** na **mesma shell** `console.ligbox.com.br` — design system React partilhado, tom caloroso BR (banco digital).
```
┌─────────────────────────────────────────────────────────────────┐
│ LIGBOX CONSOLE — console.ligbox.com.br │
│ Login único · role detecta menu │
├─────────────────────────────────────────────────────────────────┤
│ /comercial + /ops Staff Ligbox (Fase C — depois gerente) │
│ /admin Gerente domínio ← FASE A PRIORIDADE │
│ /me Utilizador email (Fase B) │
└─────────────────────────────────────────────────────────────────┘
```
Ver shell completa: [ligbox-console-shell.md](./ligbox-console-shell.md)
---
## 3. Onde vive a Área Gerente
| Opção | Decisão |
|-------|---------|
| FOSS área cliente | ❌ Só billing embebido via API — não UI principal |
| OpenPanel user panel | ❌ Backend hub — não face visível |
| **Wizard SPA `/admin`** | ✅ **Escolhido** — evolui para `console.ligbox.com.br/admin` |
| Portal Ligbox novo | ❌ Evitar duplicar — **shell unificada** Spec 035-UX |
**URL canónica:** `https://console.ligbox.com.br/admin`
**Redirect:** `https://onboard.ligbox.com.br/admin` → 301 console (legacy)
### Mockup sandbox (seguro — zero produção)
Ficheiro estático interactivo — **não liga a APIs**, estado só em memória do browser:
```
specs/035-ligbox-mail-bundles-foss-openpanel/mockups/domain-manager-sandbox.html
```
Abrir localmente:
```bash
# no CT130 ou laptop
xdg-open /opt/ligbox-spec-hub/repos/ligbox-ops-platform/specs/035-ligbox-mail-bundles-foss-openpanel/mockups/domain-manager-sandbox.html
# ou servir estático (opcional):
python3 -m http.server 8765 --directory specs/035-ligbox-mail-bundles-foss-openpanel/mockups
# → http://localhost:8765/domain-manager-sandbox.html
```
Barra vermelha **SANDBOX** sempre visível. Acções (criar conta, remover, upgrade) mostram toast «simulado» — Carbonio, FOSS, OpenPanel e Nextcloud **não são tocados**.
### Modo Live create-only (produção segura)
O mesmo ficheiro HTML inclui botão **Live create-only**:
| Modo | Comportamento |
|------|---------------|
| **Mock** | Zero API — UI only |
| **Live** | Desk API → VM112 cria domínio/contas **reais** |
**Regras de segurança (API Desk):**
- ✅ Criar cenário = domínio novo `cenario-*`.ops.ligbox.com.br` + `admin@`
- ✅ Adicionar contas **só** dentro do cenário criado
- ❌ **Delete/purge bloqueado** (HTTP 403) — nada existente apagado
- ❌ Domínios protegidos (`ligbox.com.br`, etc.) bloqueados
- ❌ Domínio que **já tem contas** não pode ser usado como cenário novo
**API:** `POST /api/v1/domain-console/sandbox/scenarios`
**Auth:** JWT Desk (`ops_lead`, `super_admin`, `technician` com `manage_vm112_domains`)
**Deploy:** código em `projects/ops-desk/api/app/domain_console_sandbox*.py` — activar no VM122
**Login único:** sessão wizard (`/api/domain-admin/auth`) + opcional SSO desde FOSS (`sso_token`).
---
## 4. Wireframe — página única (desktop)
```
╔══════════════════════════════════════════════════════════════════╗
║ LIGBOX · Gerente de Domínio empresa.com.br [Sair] ║
╠══════════════════════════════════════════════════════════════════╣
║ ║
║ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ║
║ │ Plano │ │ Contas │ │ DMARC │ │ Faturação │ ║
║ │ Business │ │ 12 / 25 │ │ ✅ Certificado│ │ Boleto/PIX │ ║
║ │ R$ 549/mês │ │ │ │ SPF DKIM OK │ │ [Pagar] │ ║
║ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ ║
║ ║
║ ┌─ Navegação lateral ─┐ ┌─ Conteúdo principal ──────────────┐ ║
║ │ 📊 Visão geral │ │ │ ║
║ │ 📧 Contas de email │ │ (secção activa — ver §5) │ ║
║ │ 📁 Nextcloud / Files │ │ │ ║
║ │ 🔐 Certificação mail │ │ │ ║
║ │ 🌐 Domínio & DNS │ │ │ ║
║ │ 💳 Plano & pagamento │ │ │ ║
║ │ 🔗 Atalhos rápidos │ │ │ ║
║ └──────────────────────┘ └────────────────────────────────────┘ ║
║ ║
╚══════════════════════════════════════════════════════════════════╝
```
### Mobile
- Cards resumo empilhados
- Menu lateral → drawer hamburger
- Tabelas contas → cards por utilizador
---
## 5. Secções da Área Gerente (Fase A)
### 5.1 Visão geral
| Widget | Fonte API | Acção |
|--------|-----------|-------|
| Plano activo + uso | Desk `bundle_entitlements` | — |
| Barra contas (12/25) | Wizard domain-admin | Link → Contas |
| DMARC score | EasyDMARC API | Link → Certificação |
| Disco mail domínio | Carbonio zmprov | — |
| Disco Files domínio | Nextcloud OCS | — |
| Últimas contas criadas | Wizard audit log | — |
### 5.2 Contas de email
**Tabela principal — tudo na mesma página, sem ir ao Carbonio Admin Console.**
| Coluna | Editable |
|--------|----------|
| Email | criar nova |
| Nome | ✅ |
| Quota mail (GB) | ✅ (≤ bundle) |
| Quota Files (GB) | ✅ (≤ bundle) |
| Nextcloud activo | toggle |
| Estado | activo / suspenso |
| Acções | reset senha · editar · remover |
**Botão «+ Nova conta»** → modal inline (não redirect):
```
Email: [ vendas ] @empresa.com.br
Nome: [ Vendas ]
Quota mail:[ 30 GB ▼ ]
Quota NC: [ 200 GB ▼ ]
☑ Criar Nextcloud Files
[ Cancelar ] [ Criar conta ]
```
**Backend:** Wizard `POST /api/domain-admin/accounts` → Carbonio + Nextcloud OCS (Spec 034).
**Limite:** se `seats_used >= max_seats` → modal «Upgrade plano» (embed FOSS ou deep-link).
### 5.3 Nextcloud / Files
| Elemento | Comportamento |
|----------|---------------|
| **Toggle «Mail no Nextcloud»** | **ON/OFF por domínio** — default OFF (Roger 2026-06-21) |
| Resumo quota total domínio | soma quotas contas |
| Lista contas com quota Files | read-only espelho §5.2 |
| Coluna «Mail NC» | ✅ / — conforme toggle domínio + conta |
| Botão «Abrir Files do domínio» | nova tab `files.{dominio}` (SSO token NC) |
| Botão «Abrir webmail» | nova tab `mail.{dominio}` (sempre disponível) |
| Política default novas contas | dropdown 100500 GB |
**UI toggle (domínio):**
```
Nextcloud / Files
─────────────────────────────────────────
☐ Activar Mail no Nextcloud (email dentro do Files)
Lê email via Carbonio — webmail mail.{dom} continua disponível
Quando activo: novas contas recebem Mail app pré-configurado.
Quando inactivo: só Files — utilizadores usam mail.{dom} ou Outlook.
```
**Backend:** `PATCH /api/domain-admin/domains/{dom}/nextcloud-mail` → wizard → OCS + entitlements.
**Nota:** gestão quota **na mesma app** — não enviar gerente ao painel admin Nextcloud.
### 5.4 Certificação mail (EasyDMARC)
| Item | UI |
|------|-----|
| SPF | ✅ / ⚠️ + texto simples |
| DKIM | ✅ / ⚠️ |
| DMARC | policy + score |
| Histórico 30 dias | gráfico simples |
| «O que significa?» | tooltip layman |
**Sem** link para easydmarc.com — dados via API Ligbox (Desk proxy).
### 5.5 Domínio & DNS (DNS Viewer — Spec 037-DNS-VIEWER)
Secção **read-only** — gerente **vê** apontamentos; **não edita** na Console. Edição via link externo (Cloudflare cliente, OpenPanel, registrador).
**Regra wizard (037):**
| Escolha onboarding | O que esta secção mostra |
|--------------------|--------------------------|
| **Trazer DNS para Ligbox** | Apontamentos **que a Ligbox aplicou / vai aplicar** (MX, SPF, DKIM, DMARC, A mail) + NS Cloudflare |
| **DNS externo / BYO / registrador** | O que está **publicamente resolvido agora** + instruções se faltar algo |
#### Layout `/admin/dominio` ou tab «Domínio & DNS»
```
┌─ DNS — empresa.com.br ─────────────────────────────────────────┐
│ [Cloudflare Ligbox] 14 registos · 6 para e-mail │
│ NS actuais: ada.ns.cloudflare.com … ✅ delegação Ligbox │
├────────────────────────────────────────────────────────────────┤
│ Função │ Nome │ Tipo │ Conteúdo │ Estado │
│ MX │ empresa.com.br │ MX │ mail.empresa… │ ✅ OK │
│ SPF │ empresa.com.br │ TXT │ v=spf1 include… │ ✅ OK │
│ DKIM │ …._domainkey │ TXT │ v=DKIM1… │ ⚠ pendente│
├────────────────────────────────────────────────────────────────┤
│ Verificação pública: MX ✅ · SPF ✅ · DKIM ⚠ · DMARC ✅ │
├────────────────────────────────────────────────────────────────┤
│ Subdomínio incluído: intranet.empresa.com.br → CNAME … │
│ [Ver instruções DNS] [Contactar suporte] [Actualizar] │
└────────────────────────────────────────────────────────────────┘
```
#### API
```http
GET /api/v1/dns/viewer/{domain}
Authorization: Bearer <domain-admin JWT>
```
Proxy Desk → agrega CF / OpenPanel / público conforme `dns_mode`. Ver [dns-viewer.md](../037-dns-multi-cloudflare-orchestration/dns-viewer.md).
#### Elementos UI
| Item | Comportamento |
|------|---------------|
| Badge origem | `DNS Ligbox` · `Cloudflare sua conta` · `OpenPanel BIND` · `Registrador externo` |
| Tabela registos | Todas as linhas relevantes (mail + subdomínio bundle) |
| NS | Actuais (público) vs Ligbox CF (se aplicável) |
| Checks mail | MX/SPF/DKIM/DMARC — reutilizar Spec 009 / `public_checks` |
| Subdomínio incluído | Linha CNAME/A do bundle §2.4 — read-only ou link suporte |
| «Ver instruções DNS» | Modal com `dns/instructions` (modo externo) |
| «Editar DNS» | **Só se BYO/OpenPanel gerido pelo cliente** — nova tab |
| Modo Ligbox gerida | Sem link CF interna — «Alterações via suporte Ligbox» |
#### Modo externo (exemplo copy)
> O seu domínio usa DNS **fora da Ligbox**. Abaixo está o que os servidores públicos respondem **agora**. Para activar email, configure os valores em «Instruções DNS» no seu registrador.
#### Modo Ligbox (exemplo copy)
> A Ligbox gere o DNS deste domínio na Cloudflare. Apontamentos abaixo estão **activos** (ou **serão aplicados** após apontar os nameservers).
#### Staff impersonate
Staff Ligbox (Spec 027) em impersonate vê links adicionais «Editar na Cloudflare (staff)» — ocultos para gerente normal.
**Critérios aceite (A4):**
1. Gerente vê tabela completa mail sem abrir Cloudflare.
2. Domínio externo mostra estado público — **não** lista preview Ligbox.
3. Domínio Ligbox pré-NS mostra NS + registos planeados.
4. Zero botões «Apagar» / «Guardar registo» nesta secção.
### 5.6 Plano, pagamento & upgrade
| Elemento | Comportamento |
|----------|---------------|
| Plano actual | nome + preço + renovação |
| **Status pagamento** | Em dia · Aguardando · Vencido |
| **Boleto bancário** | botão «Gerar / Ver boleto» → PDF ou linha digitável (gateway via FOSS) |
| **PIX QR Code** | QR inline + copia-e-cola (gateway via FOSS) |
| Uso vs limites | barras visuais |
| «Upgrade plano» | iframe FOSS checkout **ou** API FOSS embed |
| Faturas recentes | lista 3 últimas via FOSS API |
| «Ver faturação completa» | abre FOSS cliente **nova tab** (única excepção externa) |
**Regra Roger:** boleto + PIX **visíveis no `/admin`** — gerente não precisa caçar fatura noutro portal para pagar.
**Backend:** Gateway (ASAAS/Iugu) → webhook FOSS → Desk → activa entitlements quando pago.
### 5.7 Atalhos rápidos (sidebar footer)
| Atalho | Destino |
|--------|---------|
| Webmail gerente | `mail.{dom}` nova tab |
| Files gerente | `files.{dom}` SSO |
| Suporte Ligbox | Desk ticket (email gerente) |
---
## 6. Integrações invisíveis (backend)
O gerente vê **uma app**. Por baixo:
```
Domain Manager SPA (console.ligbox.com.br/admin)
├── Wizard API /api/domain-admin/* → Carbonio CRUD
├── Desk API /api/v1/domain-console/* → entitlements, DMARC, FOSS proxy
├── Nextcloud OCS (via wizard proxy) → quotas Files, activar/desactivar
├── FOSS API (via Desk proxy) → faturação, plano, upgrade
└── Gateway pagamento (via FOSS) → boleto + PIX QR
```
**OpenPanel:** zero UI exposta ao gerente — só provision backend (Spec 035 §4.1).
---
## 7. Autenticação
### 7.1 Login directo
```
POST /api/domain-admin/login
{ "email": "admin@empresa.com.br", "password": "..." }
→ JWT session (domínio no claim)
```
### 7.2 SSO desde FOSS (pós-compra)
```
FOSS cliente → «Abrir Console Gerente»
→ Desk POST /api/v1/domain-console/sso-token
→ redirect onboard.ligbox.com.br/admin?sso=TOKEN
→ wizard valida → sessão
```
### 7.3 Quem pode entrar
| Email | Acesso Área Gerente |
|-------|---------------------|
| `admin@{dom}` | ✅ sempre |
| `administrator@{dom}` | ✅ se flag Carbonio |
| Outros `@dom` | ❌ → Fase B (self-service user) |
| Staff Ligbox Desk | ✅ impersonate auditado (Spec 027) |
---
## 8. Fase B — Área Utilizador Email (placeholder)
**Status:** 📋 A estudar — **não implementar na Fase A**
Cada `{user}@{dominio}` terá portal **separado** e **limitado**:
| Funcionalidade | Carbonio nativo | UI Ligbox proposta |
|----------------|-----------------|-------------------|
| Redirects / encaminhamento | sieve / prefs | Secção «O meu email» |
| Out of office / férias | vacation | Form datas + mensagem |
| Assinatura | prefs | Editor HTML simples |
| Calendário | CalDAV | Link ou embed leve |
| Alterar senha | ✅ | Form |
| Quota pessoal | read-only | Barra uso |
| Criar contas domínio | ❌ | Só gerente |
**URL proposta Fase B:** `https://mail.{dominio}/settings` ou `onboard.ligbox.com.br/me`
**Decisão pendente Roger:** webmail Carbonio prefs nativas vs SPA Ligbox custom.
Documento futuro: `user-self-service-ui.md` (Spec 036 ou § Fase B desta spec).
---
## 9. Fases de entrega UI
| Fase | Entregável | Prioridade |
|------|------------|------------|
| **A1** | Shell SPA + login + cards resumo | P0 |
| **A2** | CRUD contas + quotas inline | P0 |
| **A3** | Nextcloud quota + toggle Mail opcional + atalho Files SSO | P1 |
| **A4** | EasyDMARC card + **DNS Viewer** (`/admin/dominio`) | P1 |
| **A5** | FOSS plano/faturação embed | P2 |
| **B*** | Self-service utilizador email | P2 futuro |
---
## 10. Critérios de aceite (Fase A)
1. Gerente entra **só** em `onboard.ligbox.com.br/admin` — gere contas **sem** abrir Carbonio Admin Console.
2. Criar conta `vendas@` + quota NC → funcional em **um modal**, ≤3 cliques.
3. Resumo plano + 12/25 contas visível no dashboard.
4. DMARC status legível (não técnico).
5. Utilizador `vendas@` **não** acede `/admin` — redirect para webmail ou 403.
6. Mobile: criar conta e ver resumo utilizável.
---
## 11. Referências
| Doc | Path |
|-----|------|
| Bundles comercial | `spec.md` |
| Domain Admin actual | Spec 010 · VM112 `DomainAdmin.jsx` |
| Nextcloud OCS | `../034-.../contracts/nextcloud-provisioning-api.md` |
| RBAC gerente | Spec 027 § client_domain_admin (a formalizar) |
| **DNS Viewer (read-only)** | [037 dns-viewer.md](../037-dns-multi-cloudflare-orchestration/dns-viewer.md) |

View file

@ -0,0 +1,144 @@
# Spec 035 — FOSSBilling Products (template)
**Actualizar após criação manual no Admin FOSS** com IDs reais.
Base URL Admin: `https://financeiro.ligbox.com.br/admin`
---
## Hosting server
| Campo | Valor |
|-------|-------|
| Nome | VM123 OpenPanel |
| Manager | OpenPanel |
| Hostname | `10.10.10.123:18087` |
| ID | 1 (existente) |
---
## Produtos a criar
### ligbox-mail-starter
```yaml
title: Ligbox Mail Starter
slug: ligbox-mail-starter
type: hosting
category: Ligbox Mail
pricing:
type: recurrent
recurrent: monthly
price: 249.00
setup: 0
plugin: OpenPanel
plugin_config:
plan: ligbox-mail-starter
description: |
Até 10 contas email · 20 GB/caixa · 100 GB Nextcloud · EasyDMARC · subdomínio
custom_fields:
- name: domain
required: true
- name: manager_email
required: true
```
### ligbox-mail-business
```yaml
title: Ligbox Mail Business
slug: ligbox-mail-business
pricing:
price: 549.00
plugin_config:
plan: ligbox-mail-business
description: |
Até 25 contas · 30 GB/caixa · 200 GB Nextcloud · EasyDMARC · subdomínio
```
### ligbox-mail-enterprise
```yaml
title: Ligbox Mail Enterprise
slug: ligbox-mail-enterprise
pricing:
price: 999.00
plugin_config:
plan: ligbox-mail-enterprise
description: |
Até 50 contas · 50 GB/caixa · 300 GB Nextcloud · EasyDMARC · subdomínio
```
### ligbox-mail-custom
```yaml
title: Ligbox Mail Personalizado
slug: ligbox-mail-custom
pricing:
type: recurrent
price: 99.00 # base fee
config_options:
- id: seats
name: Número de contas email
type: select
options:
- value: 10
price: 0
- value: 25
price: 50
- value: 30
price: 80
- value: 40
price: 120
- value: 50
price: 180
- id: mail_gb
name: GB email por conta
type: select
options:
- { value: 20, price: 0 }
- { value: 30, price: 15 }
- { value: 40, price: 30 }
- { value: 50, price: 45 }
- id: files_gb
name: GB Nextcloud por conta
type: select
options:
- { value: 100, price: 0 }
- { value: 200, price: 25 }
- { value: 300, price: 50 }
- { value: 400, price: 75 }
- { value: 500, price: 100 }
plugin_config:
plan: ligbox-mail-custom
```
---
## OpenPanel plans (OpenAdmin)
| plan_name | max_domains | notes |
|-----------|-------------|-------|
| ligbox-mail-starter | 1 | hub gerente |
| ligbox-mail-business | 1 | hub gerente |
| ligbox-mail-enterprise | 1 | hub gerente |
| ligbox-mail-custom | 1 | metadata JSON quotas |
Script provision:
```bash
ssh root@10.10.10.123
# Após criar plans no OpenAdmin, testar:
bash /opt/ligbox-ops-platform/projects/finance/deploy/vm123-finance-stack/test-foss-openpanel-order.sh
```
---
## IDs reais (preencher pós-criação)
| Produto | FOSS product_id | OP plan_id |
|---------|-----------------|------------|
| Starter | _TBD_ | _TBD_ |
| Business | _TBD_ | _TBD_ |
| Enterprise | _TBD_ | _TBD_ |
| Custom | _TBD_ | _TBD_ |

View file

@ -0,0 +1,306 @@
# Spec 035-UX — Ligbox Console — Shell unificada
**Criado:** 2026-06-21
**Solicitado por:** Roger
**Status:** ✅ Decisões UX fixadas — implementação Fase A (gerente) primeiro
**URL canónica:** `https://console.ligbox.com.br`
**Relacionado:** [domain-manager-console-ui.md](./domain-manager-console-ui.md) · [ligbox-system-admin-ui.md](./ligbox-system-admin-ui.md) · Spec **019** (Ops) · Spec **030** (Agentic Ops UI — Mission Board)
---
## 1. Decisões Roger (2026-06-21) — FIXADAS
| # | Decisão | Valor |
|---|---------|-------|
| 1 | Shell visual partilhada | ✅ **Sim** — um design system React para gerente + Admin Ligbox + Ops |
| 2 | URL única de marca | ✅ **`console.ligbox.com.br`** — login detecta role |
| 3 | Ops / chamados Wazuh | ✅ **Dentro do mesmo shell** — secção Operações (Spec 019 integrada) |
| 4 | Tom visual | ✅ **Caloroso BR** — tokens Desk (`styles.css`) + layout Spec **030** |
| 5 | Prioridade Fase A | ✅ **Gerente de domínio (cliente)** primeiro — Admin Ligbox staff depois |
| 6 | Padrões UX | ✅ **Spec 030 Mission Board** — status bar, 3 colunas, cards, contexto |
**Princípio:** FOSS, OpenPanel, Carbonio, Nextcloud **nunca** aparecem como nomes na UI. Só **Ligbox Console** com vocabulário: Email, Files, Plano, Pagamento, Certificação, Operações.
---
## 2. URL única + routing por role
```
https://console.ligbox.com.br
├── /login → detecção role pós-autenticação
├── /admin → Gerente de domínio (Fase A — PRIORIDADE)
├── /admin/contas
├── /admin/files
├── /admin/plano
└── …
├── /ops → Staff Ligbox — chamados CH-*, Wazuh (Spec 019)
├── /ops/ch/:id
└── …
├── /comercial → Staff Ligbox — preços, catálogo, clientes (Fase C)
├── /comercial/precos
└── …
└── /me → Utilizador email (Fase B — futuro)
```
### Login único — detecção de role
| Credencial | Role detectada | Redirect |
|------------|----------------|----------|
| `admin@{dominio}` + senha | `domain_manager` | `/admin` (contexto domínio) |
| `{user}@{dominio}` + senha | `domain_user` | `/me` ou webmail (Fase B) |
| `@ligbox.com.br` staff + RBAC | `ligbox_staff` | `/ops` ou `/comercial` conforme permissões |
| SSO FOSS pós-compra | `domain_manager` | `/admin?sso=TOKEN` |
**API:** `POST /api/console/login` → JWT com claims `{ role, domain?, permissions[] }`.
### Redirects legacy (Traefik CT114)
| URL antiga | Redirect |
|------------|----------|
| `onboard.ligbox.com.br/admin` | 301 → `console.ligbox.com.br/admin` |
| `onboard.ligbox.com.br/admin/*` | 301 → `console.ligbox.com.br/admin/*` |
| `desk.ligbox.com.br` | 301 → `console.ligbox.com.br/ops` (staff) |
| `desk.ligbox.com.br/admin` | 301 → `console.ligbox.com.br/comercial` |
Manter redirects **mín. 12 meses** — bookmarks e emails antigos.
---
## 3. Shell partilhada — componentes React
**Monorepo proposto:** `ligbox-console-ui/` (VM123 Docker, Spec 019 host)
```
ligbox-console-ui/
├── packages/
│ ├── design-system/ # tokens, Button, Card, Table, Modal, Toast
│ ├── shell/ # AppLayout, Sidebar, Header, CommandPalette
│ ├── domain-admin/ # rotas /admin/* ← FASE A
│ ├── ligbox-admin/ # rotas /comercial/*
│ ├── ops/ # rotas /ops/* ← Spec 019
│ └── user-self/ # rotas /me/* ← Fase B
└── apps/
└── console/ # Vite + React Router — entry console.ligbox.com.br
```
**Um `AppLayout`** — sidebar e header adaptam-se ao role:
| Elemento | Gerente | Staff Ligbox |
|----------|---------|--------------|
| Logo Ligbox | ✅ | ✅ |
| Selector domínio | ✅ (1 domínio) | ✅ (todos + impersonate) |
| Sidebar items | 67 (§4) | 810 (§4) |
| Pesquisa global ⌘K | domínio próprio | clientes, domínios, CH-* |
| Avatar / sair | ✅ | ✅ |
---
## 4. Navegação por role (mesma shell, menus diferentes)
### 4.1 Gerente de domínio — `/admin` (Fase A)
| Item sidebar | Rota | Pergunta que responde |
|--------------|------|------------------------|
| 📊 Início | `/admin` | Está tudo bem? |
| 📧 Contas | `/admin/contas` | Quem tem email? |
| 📁 Files | `/admin/files` | Armazenamento e Mail NC |
| 🔐 Certificação | `/admin/certificacao` | Email chega bem? |
| 🌐 Domínio | `/admin/dominio` | DNS correcto? |
| 💳 Plano | `/admin/plano` | Quanto pago? Boleto/PIX |
**Footer atalhos:** Webmail · Files · Suporte
### 4.2 Staff Ligbox — `/comercial` + `/ops`
**Comercial** (`/comercial`):
| Item | Rota |
|------|------|
| 📊 Início | KPIs, fila preços |
| 💰 Planos & preços | catálogo + aprovação Roger |
| 👥 Clientes | pesquisa, estado |
| 🌐 Domínios | todos, health, impersonate → `/admin` |
| 💳 Pagamentos | gateway boleto/PIX |
| ⚙️ Provisionamento | jobs (sem nome OpenPanel) |
**Operações** (`/ops`) — Spec 019 no mesmo shell:
| Item | Rota |
|------|------|
| 📊 Overview | alertas activos |
| 🔍 Discover | eventos correlacionados |
| 🎫 Chamados | `CH-*` hub |
| 🛡️ Segurança | Wazuh deep-link |
Staff com role `ops``/ops`; `commercial``/comercial`; Roger vê **ambos** no mesmo sidebar (secções agrupadas).
---
## 5. Design system — Spec 030 (Mission Board) + tom caloroso BR
Roger (2026-06-21): UX de referência = **Spec 030 Agentic Ops UI** (Mission Board) — **não** Spec 029-tickets-workspace.
### Mapa das specs (para não confundir)
| Spec | Nome | O que é |
|------|------|---------|
| **029-agentic-ops-runbooks** | Backend agentes | API, agentes A0A7, LLM Ollama, cenários, runbooks |
| **030-agentic-ops-ui** | **Mission Board UI** ✅ | Painel comando: status bar, kanban, cards, context panel |
| 029-tickets-workspace | Motor tickets Desk | KPIs tickets, filas — **spec separada**, não é a referência UX Console |
**Código referência Spec 030 (VM122 / repo):**
```
projects/ops-desk/frontend/assets/
styles.css # tokens globais Desk (creme + bordô)
agentic-ops.js # Mission Board, Fleet rail, Context panel
agentic-ops.css # grid ao-* · componentes ao-incident-card
```
Ver: [specs/030-agentic-ops-ui/spec.md](../../030-agentic-ops-ui/spec.md) · [wireframes.md](../../030-agentic-ops-ui/design/wireframes.md)
### 5.1 Padrões Spec 030 → reutilizar no Console React
| Padrão Spec 030 | O que faz | Aplicar em `/admin` gerente | Aplicar em `/ops` |
|-----------------|-----------|----------------------------|-------------------|
| **Status bar fixa** | Tier, último tick, contagens | Plano · contas · pagamento · certificação | **Reutilizar** overview agentes + tickets |
| **Layout 3 colunas** | Frota \| Board \| Contexto | Nav \| Conteúdo \| Painel detalhe | Mission Board + tickets Spec 019 |
| **Cards por severidade** | Kanban Crítico → OK | Cards contas por estado (activa/suspensa/quota) | Incident cards **já prontos** |
| **1 problema = 1 card** | Deduplicação cenário | 1 conta = 1 card (não duplicar linhas) | Manter dedup `agent_incidents` |
| **Context panel** | Thread + chat ao seleccionar | Quotas, reset senha, atalhos webmail/Files | Thread CH-* + Copiloto A6 |
| **Fleet rail** | Agentes A0A7 compactos | Sidebar secções (Início, Contas, Files…) | Frota agentes + filtro |
| **Próxima acção no card** | Ack / Abrir / Atribuir | «Criar conta» · «Pagar boleto» | CTAs operador |
| **Poll 30s** | Refresh sem flash | Actualizar quotas / pagamento | Manter |
| **Mobile tabs** | Board \| Frota \| Contexto | Contas \| Plano \| Detalhe | Responsivo |
**Referências UX Spec 030:** Mission Control · Agent Track Dashboard — mission board, inbox, timeline.
### 5.2 Tokens — herdar Desk (`styles.css`) + componentes `ao-*`
Tom **caloroso BR** vem do Desk global; layout **3 colunas** vem da Spec 030:
| Token Desk | Valor | Uso Console |
|------------|-------|-------------|
| `--bg` | `#f5f0e8` | fundo `/admin` gerente |
| `--card` | `#fffdf9` | cards |
| `--accent` | `#5c2e2e` | CTAs (bordô Ligbox) |
| `--sidebar-bg` | `#2e1218` | rail esquerda |
| `--sidebar-active-bar` | `#ff5c8a` | item activo |
| Font | **DM Sans** | global |
**Zona `/ops` agentic:** `agentic-ops.css` (superfície escura scoped) — OK para missões SIEM; gerente `/admin` usa tokens creme.
### 5.3 Padrões UX «banco digital BR» (complemento)
| Padrão | Aplicação Ligbox |
|--------|------------------|
| Cards com ícone + número grande | «12/25 contas», «R$ 549/mês» |
| Linguagem directa PT-BR | «Seu plano» não «Subscription tier» |
| CTAs contrastantes | «Criar conta», «Pagar agora», «Aprovar preço» |
| Feedback imediato | toast verde «Conta criada»; skeleton loading |
| Empty states amigáveis | «Você ainda não tem contas — vamos criar a primeira?» |
| Status chips coloridos | Em dia · Aguardando · Vencido |
| Ilustrações leves (opcional Fase 2) | empty states, onboarding |
### 5.4 `/admin` gerente — wireframe alinhado Spec 030 (3 colunas)
```
┌──────────────────────────────────────────────────────────────────────────┐
│ STATUS BAR · Business · 12/25 contas · Certif. ✅ · Pagamento em dia │
├──────────┬───────────────────────────────────────────┬───────────────────┤
│ NAV │ CONTAS (cards) │ CONTEXTO │
│ Início │ ┌─────────────────┐ ┌─────────────────┐│ vendas@ │
│ Contas │ │ vendas@ activa │ │ suporte@ activa ││ Quota mail 30GB │
│ Files │ │ 30GB · Files ✅ │ │ 20GB · Files ✅ ││ [Reset senha] │
│ Plano │ └─────────────────┘ └─────────────────┘│ [Abrir webmail] │
│ … │ [+ Nova conta] │ │
└──────────┴───────────────────────────────────────────┴───────────────────┘
```
Mesma lógica Spec 030: **seleccionar card → painel contexto à direita**.
### 5.5 O que evitar
- Jargon técnico (FOSS, OpenPanel, webhook, VM)
- Cinza corporativo frio estilo enterprise US
- Sidebars com 15+ itens
- Modais em cascata
- Tabelas sem acção inline
---
## 6. Orquestração invisível — padrão UI
Toda acção multi-backend mostra **um fluxo Ligbox**:
```
[ Criar conta ] → barra progresso «Criando sua conta…»
→ ✅ «Pronto! vendas@empresa.com.br»
→ atalhos: Abrir webmail · Abrir Files
```
Erro: mensagem humana + botão «Tentar novamente» ou «Falar com suporte». Log técnico só em `/ops` (staff).
---
## 7. Fases de implementação UI
| Fase | Entrega | Prioridade |
|------|---------|------------|
| **UX-0** | Design tokens + `AppLayout` + login role routing | P0 |
| **UX-A0** | Portar layout Spec 030 (status bar + 3 colunas + cards) | P0 |
| **UX-A** | **`/admin` gerente completo** (Roger: primeiro) | **P0** |
| UX-A1 | Início — status bar + nav rail (Spec 030) | P0 |
| UX-A2 | CRUD contas + quotas | P0 |
| UX-A3 | Files + toggle Mail NC | P1 |
| UX-A4 | Certificação + Plano/boleto PIX | P1 |
| **UX-B** | `/comercial` Admin Ligbox (preços, clientes) | P1 |
| **UX-C** | `/ops` — migrar **Spec 030 Mission Board** + tickets/chamados Spec 019 | P1 |
| **UX-D** | `/me` utilizador (Fase B) | P2 |
**Deploy:** VM123 Docker (`ligbox-console` container), Traefik `console.ligbox.com.br` → VM123.
**Motor API:** VM122 Desk + VM112 Wizard (inalterado — só UI unifica).
---
## 8. Impersonate (staff → gerente)
Staff clica «Entrar como gerente» num domínio:
```
/comercial/dominios/empresa.com.br → [ Entrar como gerente ]
→ JWT impersonate (audit Spec 027)
→ /admin?impersonate=1&domain=empresa.com.br
→ mesma UI gerente + banner amarelo «Modo suporte Ligbox»
```
Gerente **nunca** vê este modo — só staff.
---
## 9. Relacionados
| Doc | Conteúdo |
|-----|----------|
| [domain-manager-console-ui.md](./domain-manager-console-ui.md) | Detalhe secções `/admin` |
| [034-console-process-ui.md](../034-nextcloud-carbonio-vm112-integration/034-console-process-ui.md) | Processos Carbonio + Files no Console |
| [ligbox-system-admin-ui.md](./ligbox-system-admin-ui.md) | Detalhe `/comercial` |
| Spec **019** | Motor `/ops` — UI migra para shell partilhada |
| [user-self-service-ui.md](./user-self-service-ui.md) | Futuro `/me` |
---
## 10. Critérios de aceite shell
- [ ] Um login em `console.ligbox.com.br` — roles distintos, mesma marca
- [ ] Gerente não vê menus staff; staff vê `/admin` via impersonate
- [ ] Zero ocorrência «FOSS» ou «OpenPanel» na UI gerente
- [ ] `onboard.ligbox.com.br/admin` redirect 301 funcional
- [ ] Ops `/ops` no mesmo header/logo que `/admin`
- [ ] Lighthouse acessibilidade ≥ 90 na rota `/admin`

View file

@ -0,0 +1,213 @@
# Spec 035-C — Admin Ligbox (Sistema) — Console agregado staff
**Criado:** 2026-06-21
**Solicitado por:** Roger
**Status:** ✅ URL fixada — integrado em [ligbox-console-shell.md](./ligbox-console-shell.md)
**URL:** `https://console.ligbox.com.br/comercial` (staff) · `/ops` (chamados Spec 019)
**Relacionado:** Spec **035** · **024** · **027** (RBAC) · **019** (Ops Console)
---
## 1. Decisão Roger (2026-06-21)
> A página de **aprovação de preços** pertence ao **Admin Ligbox** — não ao FOSS nem a portais dispersos.
> Da mesma forma que precisamos de **uma página agregada para utilizadores** (todos os setups de user de todas as ferramentas), precisamos do **mesmo para o Admin do sistema**.
**Princípio único (3 portais):**
| Portal | Quem | O quê agrega |
|--------|------|--------------|
| **Admin Ligbox** (esta spec) | Roger + staff RBAC | **Todos** os setups **admin/plataforma** — preços, produtos, gateway, provisionamento, clientes |
| **Gerente de Domínio** | `admin@{dom}` | **Todos** os setups **do domínio** — contas, quotas, DMARC, pagamento resumo |
| **Utilizador** (Fase B) | `{user}@{dom}` | **Todos** os setups **pessoais** — OOO, assinatura, redirects, senha, Files prefs |
**Regra de ouro:** nenhum portal manda o utilizador «configurar» noutra ferramenta nativa (FOSS admin, OpenPanel UI, Nextcloud admin, Carbonio admin). APIs nativas ficam **por trás**; a UI Ligbox **agrega**.
---
## 2. Três níveis — visão completa
```
┌─────────────────────────────────────────────────────────────────┐
│ NÍVEL 0 — ADMIN LIGBOX (Sistema) ← ESTA SPEC (035-C) │
│ Roger · staff @ligbox.com.br (RBAC Spec 027) │
│ • Aprovação de preços & catálogo bundles │
│ • Gateway pagamento (boleto/PIX) — config & status │
│ • Provisioning global (OpenPanel, wizard, VM116) │
│ • Clientes / domínios / impersonate gerente │
│ • Ops: chamados, health, audit (integra Spec 019) │
└─────────────────────────────────────────────────────────────────┘
│ vende & provisiona
┌─────────────────────────────────────────────────────────────────┐
│ NÍVEL 1 — GERENTE DE DOMÍNIO ← Spec 035-UI (Fase A)│
│ admin@empresa.com.br · onboard.ligbox.com.br/admin │
│ • Plano, limites, boleto/PIX resumo │
│ • CRUD contas email + quotas Nextcloud │
│ • DMARC, DNS, upgrade plano │
└─────────────────────────────────────────────────────────────────┘
│ cria contas
┌─────────────────────────────────────────────────────────────────┐
│ NÍVEL 2 — UTILIZADOR DE EMAIL ← Spec 035-B (Fase B)│
│ vendas@empresa.com.br · onboard.ligbox.com.br/me │
│ • Redirects, OOO, assinatura, senha │
│ • Prefs Files pessoais, quota uso │
│ • (sem admin domínio · sem preços · sem DMARC global) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. Admin Ligbox — secções agregadas
### 3.1 Comercial & preços (Roger — obrigatório)
| Funcionalidade | Hoje (disperso) | UI Admin Ligbox |
|----------------|-----------------|-----------------|
| **Aprovar preços** | FOSS admin / planilha | Fila «Preços pendentes» → Roger aprova/rejeita |
| Catálogo bundles | FOSS produtos | Starter / Business / Enterprise / Custom |
| Margem & benchmark | spec.md §1.1 | Editor inline + histórico versões |
| Publicar no FOSS | manual | Botão «Publicar» → API FOSS |
| Promoções / cupons | FOSS | Lista + criar (proxy API) |
**Fluxo aprovação preços:**
```
Staff propõe alteração preço (ou import Spec)
→ Estado: rascunho
→ Fila Admin Ligbox «Aguarda Roger»
→ Roger aprova
→ Desk API → FOSSBilling actualiza produto
→ Log audit (quem, quando, valor anterior/novo)
```
Roger **nunca** precisa entrar no backoffice FOSS para aprovar preço.
### 3.2 Billing & gateway
| Funcionalidade | Backend | UI Admin Ligbox |
|----------------|---------|-----------------|
| Config gateway ASAAS/Iugu | VM123 | Credenciais, ambiente sandbox/prod |
| Webhooks pagamento | FOSS + Desk | Log últimos eventos |
| Faturas globais | FOSS | Pesquisa por cliente/domínio |
| Inadimplência | FOSS | Lista + acções (suspender bundle) |
### 3.3 Provisioning & plataforma
| Funcionalidade | Backend | UI Admin Ligbox |
|----------------|---------|-----------------|
| Jobs provisionamento | OpenPanel + Wizard | Fila, retry, erro |
| Domínios activos | Wizard + Desk | Lista + health |
| Nextcloud VM116 | OCS / admin API | Quota cluster, tenants |
| EasyDMARC contas | API ext. | Domínios registados |
| Bridge FOSS↔OP | VM123 :18087 | Status bridge |
### 3.4 Clientes & suporte
| Funcionalidade | UI Admin Ligbox |
|----------------|-----------------|
| Lista clientes FOSS | pesquisa, plano, estado pagamento |
| Impersonate gerente | abre `/admin` como domínio (auditado Spec 027) |
| Chamados | integração Spec 019 / Desk tickets |
| Leads abandonados | Spec 012 widget |
### 3.5 Ops & segurança — **mesmo shell** (Spec 019)
Chamados `CH-*`, Discover e Wazuh vivem em **`console.ligbox.com.br/ops`** — **não** app separada.
| Item | Rota |
|------|------|
| Overview alertas | `/ops` |
| Chamado hub | `/ops/ch/:id` |
| Discover | `/ops/discover` |
| Ver no Wazuh | deep-link VM104 (staff only) |
Mesmo header, logo e design system que `/admin` gerente e `/comercial`.
---
## 4. Wireframe resumo (Admin Ligbox)
```
╔══════════════════════════════════════════════════════════════════╗
║ LIGBOX ADMIN Roger ▼ Sair ║
╠══════════════════════════════════════════════════════════════════╣
║ ┌─ Sidebar ─────────┐ ┌─ Conteúdo ──────────────────────────┐ ║
║ │ 📊 Dashboard │ │ Preços pendentes (2) │ ║
║ │ 💰 Preços & catálogo│ │ ┌─────────────────────────────┐ │ ║
║ │ 💳 Gateway & faturas│ │ │ Business R$549 → R$599 │ │ ║
║ │ ⚙️ Provisioning │ │ │ [Aprovar] [Rejeitar] [Diff] │ │ ║
║ │ 👥 Clientes │ │ └─────────────────────────────┘ │ ║
║ │ 🌐 Domínios │ │ │ ║
║ │ 🎫 Chamados │ │ Catálogo publicado │ ║
║ │ 🔐 RBAC staff │ │ Starter · Business · Enterprise │ ║
║ │ 📈 Ops Console → │ │ [+ Novo bundle] [Editar preços] │ ║
║ └────────────────────┘ └─────────────────────────────────────┘ ║
╚══════════════════════════════════════════════════════════════════╝
```
---
## 5. Integrações invisíveis (backend)
```
Admin Ligbox SPA (console.ligbox.com.br/comercial + /ops)
├── Desk API /api/v1/ligbox-admin/* → RBAC, audit, orquestração
├── FOSS API (proxy) → produtos, preços, faturas
├── Gateway API (proxy) → boleto/PIX config
├── OpenPanel API (proxy) → provision jobs
├── Wizard API (proxy) → domínios, health VM112
├── Nextcloud admin API (proxy VM116) → plataforma Files
└── Spec 019 Console API → chamados, ops
```
**FOSSBilling / OpenPanel / Nextcloud admin nativos:** zero exposição ao Roger para tarefas do dia-a-dia. Só APIs.
---
## 6. RBAC (Spec 027)
| Role | Preços | Gateway | Provisioning | Impersonate | Ops |
|------|--------|---------|--------------|-------------|-----|
| `ligbox_owner` (Roger) | aprovar | sim | sim | sim | sim |
| `ligbox_commercial` | propor | read | read | não | não |
| `ligbox_ops` | read | read | sim | sim (audit) | sim |
| `ligbox_support` | read | read | read | limitado | tickets |
---
## 7. Relação com outros documentos
| Documento | Nível |
|-----------|-------|
| **Este ficheiro** | Nível 0 — Admin Ligbox sistema |
| [domain-manager-console-ui.md](./domain-manager-console-ui.md) | Nível 1 — Gerente domínio |
| [user-self-service-ui.md](./user-self-service-ui.md) | Nível 2 — Utilizador email |
| [spec.md](./spec.md) §4 | Arquitectura backend |
| Spec **019** | Ops Console (secção dentro ou link Admin Ligbox) |
---
## 8. Fases de implementação
| Fase | Entrega |
|------|---------|
| **C0** | Wireframe + RBAC roles «preços» |
| **C1** | Fila aprovação preços → publicar FOSS |
| **C2** | Dashboard clientes + domínios + gateway status |
| **C3** | Provisioning monitor + impersonate gerente |
| **C4** | Unificar navegação com Ops Console Spec 019 |
**Depende de:** Fase 0 Spec 035 (produtos FOSS rascunho) para alimentar fila de preços.
---
## 9. Próximo passo
1. ~~Roger confirma URL~~**`console.ligbox.com.br`** ✅
2. Implementar **UX-A** gerente `/admin` primeiro (ligbox-console-shell §7)
3. Depois **UX-B** `/comercial` + **UX-C** `/ops`

View file

@ -0,0 +1,21 @@
# Demo Site — Credenciais (acesso EXTERNO)
> **Protótipo Desk descontinuado.**
> `desk.ligbox.com.br/demo-domain-manager.html` → redirecciona para o portal real.
---
## Portal real (Gerente de Domínio)
| Item | URL pública |
|------|-------------|
| **Painel** | **https://onboard.ligbox.com.br/admin** |
| **Login demo** | `admin@cenario-demo.ops.ligbox.com.br` / `Demo805353` |
| **Webmail demo** | https://mail.cenario-demo.ops.ligbox.com.br/ |
---
## Wireframe interno (repo only — não público)
Mockup sandbox para design:
`specs/035-.../mockups/domain-manager-sandbox.html` (só no Git, não no Desk)

View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta http-equiv="refresh" content="8;url=https://onboard.ligbox.com.br/admin"/>
<title>Ligbox — Protótipo descontinuado</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&display=swap" rel="stylesheet"/>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: 'DM Sans', system-ui, sans-serif;
background: #f5f1eb;
color: #2a2520;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.card {
max-width: 520px;
background: #fffdf9;
border: 1px solid #ddd4c8;
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.tag {
display: inline-block;
background: #faecd8;
color: #a05a18;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.35rem 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
p { font-size: 0.9rem; color: #6b6560; line-height: 1.55; margin: 0 0 1rem; }
.btn {
display: inline-block;
background: #5c2e2e;
color: #fff;
text-decoration: none;
padding: 0.65rem 1.25rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.btn:hover { opacity: 0.92; }
.creds {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid #ddd4c8;
font-size: 0.78rem;
color: #6b6560;
text-align: left;
}
code { background: #f8f4ee; padding: 0.1rem 0.35rem; border-radius: 3px; }
</style>
</head>
<body>
<div class="card">
<div class="tag">Protótipo descontinuado</div>
<h1>Este link era um rascunho interno</h1>
<p>
O portal do <strong>Gerente de Domínio</strong> está em
<strong>onboard.ligbox.com.br/admin</strong> — não neste endereço do Desk.
</p>
<p>Redirecionamento automático em 8 segundos…</p>
<a class="btn" href="https://onboard.ligbox.com.br/admin">Ir para o Painel Gerente</a>
<div class="creds">
<strong>Demo (teste):</strong><br/>
Email: <code>admin@cenario-demo.ops.ligbox.com.br</code><br/>
Senha: <code>Demo805353</code>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,632 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Ligbox — Área Gerente de Domínio (Sandbox)</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap" rel="stylesheet"/>
<style>
:root {
--bg: #f5f1eb;
--surface: #fffdf9;
--surface-2: #f8f4ee;
--border: #ddd4c8;
--ink: #2a2520;
--muted: #6b6560;
--accent: #5c2e2e;
--accent-soft: #f3e8e8;
--ok: #1a6648;
--ok-bg: #dff0e8;
--warn: #a05a18;
--warn-bg: #faecd8;
--mail: #1a6b5c;
--mail-bg: #e8f5f1;
--files: #2a5298;
--files-bg: #eaf0fa;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'DM Sans', system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
min-height: 100vh;
}
.demo-banner {
background: #a05a18;
color: #fff;
text-align: center;
padding: 0.55rem 1rem;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.03em;
border-bottom: 3px solid #7a4412;
}
.demo-banner strong { font-size: 0.95rem; }
.sandbox-bar {
background: var(--accent);
color: #fff;
text-align: center;
padding: 0.45rem 1rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.sandbox-bar strong { font-weight: 700; }
.app { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1rem 2rem; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.brand { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; }
.brand span { color: var(--muted); font-weight: 500; font-size: 0.85rem; margin-left: 0.5rem; }
.domain-pill {
background: var(--surface);
border: 1px solid var(--border);
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
}
.card-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.35rem; }
.card-value { font-size: 1.15rem; font-weight: 700; }
.card-sub { font-size: 0.75rem; color: var(--muted); margin-top: 0.25rem; }
.badge-ok { display: inline-block; background: var(--ok-bg); color: var(--ok); font-size: 0.72rem; font-weight: 600; padding: 0.15rem 0.5rem; border-radius: 4px; }
.layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 1rem;
align-items: start;
}
@media (max-width: 768px) {
.layout { grid-template-columns: 1fr; }
.nav { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.nav button { flex: 1 1 auto; border-radius: 8px !important; border-right: 1px solid var(--border) !important; }
}
.nav {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.nav button {
display: block;
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
padding: 0.65rem 1rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
color: var(--muted);
}
.nav button:last-child { border-bottom: none; }
.nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.nav button:hover:not(.active) { background: var(--surface-2); }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
min-height: 420px;
}
.panel h2 { margin: 0 0 0.35rem; font-size: 1.15rem; }
.panel p.desc { margin: 0 0 1rem; font-size: 0.82rem; color: var(--muted); }
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
th, td { text-align: left; padding: 0.55rem 0.5rem; border-bottom: 1px solid var(--border); }
th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
.btn {
border: 1px solid var(--border);
background: var(--surface-2);
padding: 0.45rem 0.85rem;
border-radius: 6px;
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.btn:hover { background: var(--border); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { opacity: 0.92; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.72rem; }
.btn-danger { color: #9b2c2c; border-color: #e8c4c4; background: #fdf5f5; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.progress-wrap { margin: 0.75rem 0; }
.progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; margin-bottom: 0.25rem; }
.progress-bar { height: 8px; background: var(--surface-2); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--mail); border-radius: 4px; transition: width 0.3s; }
.dmarc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.dmarc-item { background: var(--surface-2); border-radius: 8px; padding: 1rem; text-align: center; }
.dmarc-item .icon { font-size: 1.5rem; margin-bottom: 0.35rem; }
.dns-row { display: flex; justify-content: space-between; align-items: center; padding: 0.65rem 0; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
.modal-backdrop {
position: fixed; inset: 0; background: rgba(42,37,32,0.45);
display: none; align-items: center; justify-content: center; z-index: 100; padding: 1rem;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--surface); border-radius: 12px; max-width: 440px; width: 100%;
border: 1px solid var(--border); padding: 1.25rem;
}
.modal h3 { margin: 0 0 1rem; font-size: 1rem; }
.field { margin-bottom: 0.85rem; }
.field label { display: block; font-size: 0.72rem; font-weight: 600; color: var(--muted); margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.04em; }
.field input, .field select {
width: 100%; padding: 0.5rem 0.65rem; border: 1px solid var(--border);
border-radius: 6px; font: inherit; font-size: 0.85rem; background: #fff;
}
.field-row { display: flex; gap: 0.5rem; align-items: center; }
.field-row input { flex: 1; }
.field-suffix { font-size: 0.82rem; color: var(--muted); white-space: nowrap; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.toast {
position: fixed; bottom: 1.25rem; right: 1.25rem; max-width: 340px;
background: var(--ink); color: #fff; padding: 0.75rem 1rem; border-radius: 8px;
font-size: 0.8rem; z-index: 200; opacity: 0; transform: translateY(8px);
transition: opacity 0.25s, transform 0.25s; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
.hidden { display: none !important; }
.protected-tag { font-size:0.65rem;background:var(--warn-bg);color:var(--warn);padding:0.1rem 0.35rem;border-radius:3px;margin-left:0.25rem; }
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1rem; }
.stat-box { background: var(--surface-2); border-radius: 8px; padding: 0.85rem; }
.stat-box.mail { border-left: 3px solid var(--mail); }
.stat-box.files { border-left: 3px solid var(--files); }
.upgrade-box {
background: var(--accent-soft); border: 1px dashed #d4a8a8;
border-radius: 8px; padding: 1rem; margin-top: 1rem; font-size: 0.82rem;
}
</style>
</head>
<body>
<div class="demo-banner" id="demo-site-banner">
<strong>DEMO SITE</strong> — Ambiente de demonstração Ligbox · não usar para produção real
<div id="demo-creds" style="font-size:0.72rem;font-weight:500;margin-top:0.35rem;opacity:0.95">
Desk API: <code>admin</code> / <code>Demo805353</code> ·
Gerente email: <code>admin@cenario-demo.ops.ligbox.com.br</code> / <code>Demo805353</code> ·
Webmail: <a href="https://mail.cenario-demo.ops.ligbox.com.br/" style="color:#fff" target="_blank">mail.cenario-demo.ops.ligbox.com.br</a>
</div>
</div>
<div class="sandbox-bar" id="mode-bar">
<strong id="mode-label">SANDBOX MOCK</strong><span id="mode-desc">dados fictícios · zero API</span>
<span style="margin-left:1rem">
<button type="button" class="btn btn-sm" id="btn-mode-mock" style="margin-right:0.35rem">Mock</button>
<button type="button" class="btn btn-sm" id="btn-mode-live">Live create-only</button>
</span>
</div>
<div class="sandbox-bar hidden" id="live-config" style="background:#1a6b5c;font-size:0.72rem">
API Desk: <input id="api-base" value="https://desk.ligbox.com.br" style="width:240px;padding:2px 6px;border-radius:4px;border:none"/>
Token: <input id="api-token" type="password" placeholder="Bearer JWT Desk" style="width:280px;padding:2px 6px;border-radius:4px;border:none"/>
<button type="button" class="btn btn-sm" id="btn-new-scenario" style="margin-left:0.5rem">+ Cenário real</button>
</div>
<div class="app">
<header class="topbar">
<div class="brand">Ligbox <span>Área Gerente de Domínio</span></div>
<div class="domain-pill" id="domain-label">empresa.com.br</div>
</header>
<div class="cards" id="summary-cards"></div>
<div class="layout">
<nav class="nav" id="nav"></nav>
<main class="panel" id="panel"></main>
</div>
</div>
<div class="modal-backdrop" id="modal-account">
<div class="modal">
<h3>Nova conta de email</h3>
<div class="field">
<label>Email</label>
<div class="field-row">
<input type="text" id="new-local" placeholder="vendas"/>
<span class="field-suffix" id="new-domain-suffix">@empresa.com.br</span>
</div>
</div>
<div class="field">
<label>Nome</label>
<input type="text" id="new-name" placeholder="Vendas"/>
</div>
<div class="field">
<label>Quota mail (GB)</label>
<select id="new-mail-gb"></select>
</div>
<div class="field">
<label>Quota Nextcloud (GB)</label>
<select id="new-files-gb"></select>
</div>
<div class="field">
<label><input type="checkbox" id="new-nc" checked/> Criar Nextcloud Files</label>
</div>
<div class="modal-actions">
<button type="button" class="btn" id="modal-cancel">Cancelar</button>
<button type="button" class="btn btn-primary" id="modal-save">Criar conta (simulado)</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
/* ── MOCK (default) ou LIVE create-only via Desk API ── */
const MODE = { kind: 'mock', scenarioId: 'c31581f3-a6fd-456f-907c-eb03b6728aed', apiBase: '', token: '' };
const STATE = {
domain: 'empresa.com.br',
plan: { name: 'Business', price: 549, maxSeats: 25, mailGbDefault: 30, filesGbDefault: 200 },
billing: { status: 'Em dia', nextRenewal: '2026-07-21' },
dmarc: { spf: true, dkim: true, dmarc: true, score: 98 },
dns: { mx: true, aMail: true, subdomain: 'intranet.empresa.com.br' },
accounts: [
{ email: 'admin@empresa.com.br', name: 'Administrador', mailGb: 30, filesGb: 200, nc: true, active: true, role: 'gerente' },
{ email: 'vendas@empresa.com.br', name: 'Vendas', mailGb: 30, filesGb: 200, nc: true, active: true },
{ email: 'financeiro@empresa.com.br', name: 'Financeiro', mailGb: 30, filesGb: 150, nc: true, active: true },
{ email: 'suporte@empresa.com.br', name: 'Suporte', mailGb: 20, filesGb: 100, nc: true, active: true },
],
section: 'overview',
};
const SECTIONS = [
{ id: 'overview', label: 'Visão geral' },
{ id: 'accounts', label: 'Contas de email' },
{ id: 'files', label: 'Nextcloud / Files' },
{ id: 'dmarc', label: 'Certificação mail' },
{ id: 'dns', label: 'Domínio & DNS' },
{ id: 'billing', label: 'Plano & faturação' },
];
function seatsUsed() { return STATE.accounts.filter(a => a.active).length; }
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 4200);
}
async function apiLive(path, opts = {}) {
const base = (document.getElementById('api-base').value || '').replace(/\/$/, '');
const token = document.getElementById('api-token').value.trim();
if (!base || !token) throw new Error('Configure API Desk + token JWT');
const r = await fetch(base + path, {
...opts,
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
...(opts.headers || {}),
},
});
const text = await r.text();
let data;
try { data = JSON.parse(text); } catch { data = { detail: text }; }
if (!r.ok) throw new Error(data.detail || data.error || `HTTP ${r.status}`);
return data;
}
function applyLiveState(payload) {
STATE.domain = payload.domain;
STATE.plan = {
name: payload.plan.name,
price: payload.plan.price || 549,
maxSeats: payload.plan.maxSeats || 25,
mailGbDefault: payload.plan.mailGbDefault || 30,
filesGbDefault: payload.plan.filesGbDefault || 200,
};
STATE.billing = payload.billing || { status: 'Sandbox', nextRenewal: '—' };
STATE.dmarc = payload.dmarc || STATE.dmarc;
STATE.dns = payload.dns || STATE.dns;
STATE.accounts = (payload.accounts || []).map(a => ({
email: a.email,
name: a.name || a.email.split('@')[0],
mailGb: a.mail_gb || 30,
filesGb: a.files_gb || 200,
nc: a.nc !== false,
active: a.active !== false,
role: a.email.startsWith('admin@') ? 'gerente' : undefined,
protected: a.protected,
sandboxCreated: a.sandbox_created,
}));
document.getElementById('domain-label').textContent = STATE.domain;
}
async function loadLiveScenario() {
if (!MODE.scenarioId) return;
const data = await apiLive(`/api/v1/domain-console/sandbox/scenarios/${MODE.scenarioId}/state`);
applyLiveState(data);
render();
toast('Estado live carregado — delete bloqueado');
}
async function createLiveScenario() {
const label = prompt('Nome do cenário (ex: Demo Cliente X):', 'Demo Roger');
if (!label) return;
const cfg = await apiLive('/api/v1/domain-console/sandbox/config');
const res = await apiLive('/api/v1/domain-console/sandbox/scenarios', {
method: 'POST',
body: JSON.stringify({
label,
domain: 'cenario-demo.ops.ligbox.com.br',
admin_password: 'Demo805353',
}),
});
MODE.scenarioId = res.scenario.id;
alert(
`Cenário REAL criado em produção (create-only):\n\n` +
`Domínio: ${res.scenario.domain}\n` +
`Admin: ${res.credentials.admin_email}\n` +
`Senha: ${res.credentials.password}\n\n` +
`Webmail: ${res.credentials.webmail}\n\n` +
`Nada existente foi apagado. Delete continua bloqueado.`
);
applyLiveState(await apiLive(`/api/v1/domain-console/sandbox/scenarios/${MODE.scenarioId}/state`));
render();
}
function setMode(kind) {
MODE.kind = kind;
document.getElementById('live-config').classList.toggle('hidden', kind !== 'live');
document.getElementById('mode-label').textContent = kind === 'live' ? 'SANDBOX LIVE' : 'SANDBOX MOCK';
document.getElementById('mode-desc').textContent = kind === 'live'
? 'create-only · VM112 real · delete bloqueado'
: 'dados fictícios · zero API';
if (kind === 'mock') { MODE.scenarioId = null; render(); }
}
document.getElementById('btn-mode-mock').addEventListener('click', () => setMode('mock'));
document.getElementById('btn-mode-live').addEventListener('click', () => setMode('live'));
document.getElementById('btn-new-scenario').addEventListener('click', () => {
createLiveScenario().catch(e => toast('Erro: ' + e.message));
});
function renderSummary() {
const used = seatsUsed();
const max = STATE.plan.maxSeats;
document.getElementById('summary-cards').innerHTML = `
<div class="card"><div class="card-label">Plano</div><div class="card-value">${STATE.plan.name}</div><div class="card-sub">R$ ${STATE.plan.price}/mês</div></div>
<div class="card"><div class="card-label">Contas</div><div class="card-value">${used} / ${max}</div><div class="card-sub">${max - used} disponíveis</div></div>
<div class="card"><div class="card-label">DMARC</div><div class="card-value"><span class="badge-ok">Certificado</span></div><div class="card-sub">Score ${STATE.dmarc.score}%</div></div>
<div class="card"><div class="card-label">Faturação</div><div class="card-value">${STATE.billing.status}</div><div class="card-sub">Renova ${STATE.billing.nextRenewal}</div></div>
`;
}
function renderNav() {
document.getElementById('nav').innerHTML = SECTIONS.map(s =>
`<button type="button" data-section="${s.id}" class="${STATE.section === s.id ? 'active' : ''}">${s.label}</button>`
).join('');
document.querySelectorAll('#nav button').forEach(btn => {
btn.addEventListener('click', () => { STATE.section = btn.dataset.section; render(); });
});
}
function renderOverview() {
const used = seatsUsed();
const pct = Math.round((used / STATE.plan.maxSeats) * 100);
const recent = STATE.accounts.slice(-3).reverse();
return `
<h2>Visão geral</h2>
<p class="desc">Resumo do domínio ${STATE.domain} — sandbox, sem ligação a servidores reais.</p>
<div class="progress-wrap">
<div class="progress-label"><span>Contas utilizadas</span><span>${used} / ${STATE.plan.maxSeats}</span></div>
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
</div>
<div class="stat-grid">
<div class="stat-box mail"><strong>Mail (Carbonio)</strong><br/>${STATE.accounts.reduce((s,a)=>s+a.mailGb,0)} GB atribuídos</div>
<div class="stat-box files"><strong>Files (Nextcloud)</strong><br/>${STATE.accounts.reduce((s,a)=>s+(a.nc?a.filesGb:0),0)} GB atribuídos</div>
</div>
<p style="margin-top:1.25rem;font-size:0.82rem;color:var(--muted)">Contas recentes:</p>
<ul style="font-size:0.82rem;margin:0.25rem 0 0 1rem;padding:0">${recent.map(a=>`<li>${a.email} — ${a.name}</li>`).join('')}</ul>
`;
}
function renderAccounts() {
const rows = STATE.accounts.map((a, i) => `
<tr>
<td>${a.email}${a.role === 'gerente' ? ' <span class="badge-ok" style="font-size:0.65rem">gerente</span>' : ''}</td>
<td>${a.name}</td>
<td>${a.mailGb} GB</td>
<td>${a.nc ? a.filesGb + ' GB' : '—'}</td>
<td>${a.active ? '<span class="badge-ok">Activo</span>' : 'Suspenso'}${a.protected ? '<span class="protected-tag">protegido</span>' : ''}</td>
<td>
${MODE.kind === 'mock' && a.role !== 'gerente' ? `<button type="button" class="btn btn-sm btn-danger" data-del="${i}">Remover</button>` : '—'}
</td>
</tr>
`).join('');
return `
<div class="toolbar">
<div><h2 style="margin:0">Contas de email</h2></div>
<button type="button" class="btn btn-primary" id="btn-new-account">+ Nova conta</button>
</div>
<p class="desc">Criar e gerir contas — alterações só na memória deste browser.</p>
<table><thead><tr><th>Email</th><th>Nome</th><th>Mail</th><th>Nextcloud</th><th>Estado</th><th></th></tr></thead><tbody>${rows}</tbody></table>
`;
}
function renderFiles() {
const total = STATE.accounts.filter(a=>a.nc).reduce((s,a)=>s+a.filesGb,0);
return `
<h2>Nextcloud / Files</h2>
<p class="desc">Quota Files por conta — URL produção: files.${STATE.domain}</p>
<div class="stat-box files" style="margin-bottom:1rem"><strong>${total} GB</strong> atribuídos no domínio (simulado)</div>
<table><thead><tr><th>Conta</th><th>Quota Files</th><th>Nextcloud</th></tr></thead><tbody>
${STATE.accounts.filter(a=>a.nc).map(a=>`<tr><td>${a.email}</td><td>${a.filesGb} GB</td><td><button type="button" class="btn btn-sm" data-open-files="${a.email}">Abrir Files (sim.)</button></td></tr>`).join('')}
</tbody></table>
<p style="font-size:0.75rem;color:var(--muted);margin-top:1rem">Em produção: SSO token → files.${STATE.domain}. Aqui: toast apenas.</p>
`;
}
function renderDmarc() {
const ok = (v) => v ? '✅' : '⚠️';
return `
<h2>Certificação mail (EasyDMARC)</h2>
<p class="desc">Ligbox Certified Mail — dados mock. Produção: API Desk proxy.</p>
<div class="dmarc-grid">
<div class="dmarc-item"><div class="icon">${ok(STATE.dmarc.spf)}</div><strong>SPF</strong><br/><span style="font-size:0.75rem;color:var(--muted)">Alinhado</span></div>
<div class="dmarc-item"><div class="icon">${ok(STATE.dmarc.dkim)}</div><strong>DKIM</strong><br/><span style="font-size:0.75rem;color:var(--muted)">Selector ligbox</span></div>
<div class="dmarc-item"><div class="icon">${ok(STATE.dmarc.dmarc)}</div><strong>DMARC</strong><br/><span style="font-size:0.75rem;color:var(--muted)">p=quarantine</span></div>
</div>
<div class="upgrade-box" style="margin-top:1.25rem">Score global: <strong>${STATE.dmarc.score}%</strong> — entregabilidade excelente (simulado)</div>
`;
}
function renderDns() {
const ok = (v) => v ? '<span class="badge-ok">OK</span>' : '<span style="color:var(--warn)">Verificar</span>';
return `
<h2>Domínio & DNS</h2>
<p class="desc">Status registos — sandbox. Produção: wizard checks Spec 010.</p>
<div class="dns-row"><span>MX mail.${STATE.domain}</span>${ok(STATE.dns.mx)}</div>
<div class="dns-row"><span>A mail.${STATE.domain}</span>${ok(STATE.dns.aMail)}</div>
<div class="dns-row"><span>Subdomínio incluído</span><code style="font-size:0.8rem">${STATE.dns.subdomain}</code></div>
<button type="button" class="btn" style="margin-top:1rem" id="btn-dns-check">Verificação DNS avançada (sim.)</button>
`;
}
function renderBilling() {
const used = seatsUsed();
return `
<h2>Plano & faturação</h2>
<p class="desc">Resumo FOSSBilling — embed futuro. Nenhuma chamada a financeiro.ligbox.com.br.</p>
<table><tbody>
<tr><td><strong>Plano actual</strong></td><td>${STATE.plan.name} — R$ ${STATE.plan.price}/mês</td></tr>
<tr><td>Contas incluídas</td><td>${used} / ${STATE.plan.maxSeats}</td></tr>
<tr><td>Mail por conta</td><td>até ${STATE.plan.mailGbDefault} GB</td></tr>
<tr><td>Nextcloud por conta</td><td>até ${STATE.plan.filesGbDefault} GB</td></tr>
<tr><td>Próxima renovação</td><td>${STATE.billing.nextRenewal}</td></tr>
</tbody></table>
<div class="upgrade-box">
<strong>Upgrade simulado</strong> — Enterprise (50 contas, 50 GB mail) — R$ 999/mês<br/>
<button type="button" class="btn btn-primary" style="margin-top:0.75rem" id="btn-upgrade">Simular upgrade</button>
</div>
`;
}
function renderPanel() {
const fns = { overview: renderOverview, accounts: renderAccounts, files: renderFiles, dmarc: renderDmarc, dns: renderDns, billing: renderBilling };
document.getElementById('panel').innerHTML = fns[STATE.section]();
bindPanelEvents();
}
function bindPanelEvents() {
document.getElementById('btn-new-account')?.addEventListener('click', openModal);
document.querySelectorAll('[data-del]').forEach(btn => {
btn.addEventListener('click', () => {
const i = +btn.dataset.del;
const email = STATE.accounts[i].email;
STATE.accounts.splice(i, 1);
toast(`Removido ${email} — simulado, produção intacta`);
render();
});
});
document.querySelectorAll('[data-open-files]').forEach(btn => {
btn.addEventListener('click', () => toast(`Abriria files.${STATE.domain} com SSO para ${btn.dataset.openFiles} (simulado)`));
});
document.getElementById('btn-dns-check')?.addEventListener('click', () => toast('Modal DNS avançado — EasyDMARC/MXToolbox (simulado)'));
document.getElementById('btn-upgrade')?.addEventListener('click', () => {
STATE.plan = { name: 'Enterprise', price: 999, maxSeats: 50, mailGbDefault: 50, filesGbDefault: 500 };
toast('Upgrade para Enterprise simulado — FOSS não alterado');
render();
});
}
function openModal() {
if (MODE.kind === 'live' && !MODE.scenarioId) {
toast('Crie primeiro um cenário real (+ Cenário real)');
return;
}
if (seatsUsed() >= STATE.plan.maxSeats) {
toast(`Limite ${STATE.plan.maxSeats} contas — simule upgrade em Plano & faturação`);
STATE.section = 'billing';
render();
return;
}
document.getElementById('new-domain-suffix').textContent = '@' + STATE.domain;
const mailSel = document.getElementById('new-mail-gb');
const filesSel = document.getElementById('new-files-gb');
mailSel.innerHTML = [20,30,40,50].map(g=>`<option value="${g}" ${g===STATE.plan.mailGbDefault?'selected':''}>${g} GB</option>`).join('');
filesSel.innerHTML = [100,200,300,400,500].map(g=>`<option value="${g}" ${g===STATE.plan.filesGbDefault?'selected':''}>${g} GB</option>`).join('');
document.getElementById('new-local').value = '';
document.getElementById('new-name').value = '';
document.getElementById('new-nc').checked = true;
document.getElementById('modal-account').classList.add('open');
}
function closeModal() { document.getElementById('modal-account').classList.remove('open'); }
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('modal-save').addEventListener('click', async () => {
const local = document.getElementById('new-local').value.trim().toLowerCase();
const name = document.getElementById('new-name').value.trim() || local;
if (!local) { toast('Indique o nome da caixa (ex: vendas)'); return; }
const email = local + '@' + STATE.domain;
if (MODE.kind === 'live' && MODE.scenarioId) {
try {
await apiLive(`/api/v1/domain-console/sandbox/scenarios/${MODE.scenarioId}/accounts`, {
method: 'POST',
body: JSON.stringify({
local_part: local,
display_name: name,
}),
});
closeModal();
await loadLiveScenario();
toast(`Criada ${email} em produção (create-only)`);
} catch (e) {
toast('Erro live: ' + e.message);
}
return;
}
if (STATE.accounts.some(a => a.email === email)) { toast('Conta já existe (sandbox)'); return; }
STATE.accounts.push({
email, name,
mailGb: +document.getElementById('new-mail-gb').value,
filesGb: +document.getElementById('new-files-gb').value,
nc: document.getElementById('new-nc').checked,
active: true,
});
closeModal();
toast(`Criada ${email} + Nextcloud — simulado, zero APIs`);
render();
});
document.getElementById('modal-account').addEventListener('click', e => {
if (e.target.id === 'modal-account') closeModal();
});
function render() {
renderSummary();
renderNav();
renderPanel();
}
render();
(function () {
const p = new URLSearchParams(location.search);
if (p.get('live') === '1' || p.get('token')) {
if (p.get('token')) document.getElementById('api-token').value = p.get('token');
setMode('live');
loadLiveScenario().catch(() => {});
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,696 @@
# Spec 035 — Bundles Ligbox Mail (FOSSBilling + OpenPanel + Nextcloud)
**Criado:** 2026-06-21
**Solicitado por:** Roger
**Status:** 📋 Draft — aguarda aprovação comercial + Fase 0
**Prioridade:** P1
**Relacionado:** Spec **024** (FOSS/OpenPanel) · **028** (bridge CE) · **034** (Nextcloud) · **027** (RBAC) · **010** (Domain Admin) · **018** (catálogo serviços) · **037** ([dns-viewer.md](../037-dns-multi-cloudflare-orchestration/dns-viewer.md))
---
## Resumo executivo
Definir **como vender, provisionar e administrar** pacotes de email profissional Ligbox (Carbonio VM112) + Nextcloud (VM116) + certificação EasyDMARC, usando:
| Camada | Função |
|--------|--------|
| **FOSSBilling** (VM123) | Catálogo, preços, pedidos, faturação, área cliente |
| **OpenPanel** (VM123) | Provisionamento hub (backend) — **sem UI exposta ao gerente** |
| **Área Gerente de Domínio** | Console único SPA — ver [domain-manager-console-ui.md](./domain-manager-console-ui.md) |
| **Wizard Domain Admin** (VM112) | Criar/gerir contas email, quotas, Nextcloud por domínio |
| **Desk** (VM122) | Orquestração, health, RBAC staff Ligbox |
**Princípio central:** o **Gerente de Domínio** (`admin@{dominio}` ou titular FOSS) configura o bundle e habilita contas. **Utilizadores finais** de email acedem só a webmail + Nextcloud Files — **sem** painel administrativo.
---
## 1. Ofertas comerciais
### 1.1 Planos base (bundles fixos)
Estes são os **templates de venda** no FOSSBilling. O cliente pode comprar directamente ou usar como ponto de partida para personalização.
| Código FOSS | Nome comercial | Contas email (máx.) | Quota mail / conta | Nextcloud / conta | Subdomínio | Preço sugerido Ligbox* |
|-------------|----------------|---------------------|--------------------|--------------------|------------|-------------------------|
| `ligbox-mail-starter` | **Ligbox Mail Starter** | 10 | 20 GB | 100 GB (config. 100200) | 1 incluído | **R$ 249/mês** |
| `ligbox-mail-business` | **Ligbox Mail Business** | 25 | 30 GB | 200 GB (config. 100300) | 1 incluído | **R$ 549/mês** |
| `ligbox-mail-enterprise` | **Ligbox Mail Enterprise** | 50 | 50 GB | 300 GB (config. 100500) | 1 incluído | **R$ 999/mês** |
\* Preços sugeridos — Roger valida margem antes de publicar no FOSS.
**Incluído em todos os bundles:**
- Email `@seudominio.com.br` (Carbonio — webmail, IMAP, SMTP, calendário, contactos)
- Nextcloud Files em `files.{dominio}` (sync desktop/mobile, partilha, links)
- Certificação email EasyDMARC (SPF, DKIM, DMARC monitorizado — ver §4)
- 1 conta **Gerente de Domínio** com painel administrativo
- Suporte DNS (MX, SPF, DMARC) via wizard onboarding
- **Painel DNS read-only** (gerente + staff) — Spec **037-DNS-VIEWER**
- TLS Let's Encrypt (mail + files)
### 1.2 Bundle personalizado (configurador)
Cliente escolhe combinação dentro dos limites operacionais:
| Parâmetro | Opções | Notas |
|-----------|--------|-------|
| **Nº contas email** | 10 · 25 · 30 · 40 · 50 | Hard cap inicial = 50 (escalar após VM116) |
| **Quota mail / conta** | 20 · 30 · 40 · 50 GB | Aplica-se Carbonio (hot tier VM112) |
| **Quota Nextcloud / conta** | 100 · 200 · 300 · 400 · 500 GB | Aplica-se VM116 Files |
| **Subdomínio** | 1 incluído | ex.: `mail.empresa.com.br` ou site CMS add-on |
**Código FOSS:** `ligbox-mail-custom` — produto **configurável** com opções FOSS (ver §5).
**Exemplo pedido Roger:** 10 contas × 50 GB mail + 500 GB Nextcloud cada:
```
ligbox-mail-custom
seats=10
mail_gb=50
files_gb=500
→ Preço calculado: base + (seats × mail_rate) + (seats × files_rate)
→ Estimativa: ~R$ 890/mês (ver fórmula §1.4)
```
### 1.3 Benchmark concorrência (referência Jun/2026)
| Concorrente | Plano | Preço / utilizador / mês | Storage mail | Cloud files | DMARC incluído |
|-------------|-------|--------------------------|--------------|-------------|----------------|
| **Google Workspace** | Business Starter | **R$ 40,90** (anual) · USD 7 | 30 GB pooled | Drive incluído | Não |
| **Google Workspace** | Business Standard | **R$ 81,80** (anual) · USD 14 | 2 TB pooled | Drive | Não |
| **Microsoft 365** | Business Basic | **~USD 6** (~R$ 35) | 50 GB Exchange | 1 TB OneDrive | Não |
| **Microsoft 365** | Business Standard | **~USD 12,50** (~R$ 72) | 50 GB | 1 TB + Office | Não |
| **Zoho Mail** | Premium (BR) | **R$ 20** (anual) | 50 GB | WorkDrive extra | Não |
| **EasyDMARC** | Plus (MSP) | **~USD 36/mês** (~R$ 200) | — | — | 2 domínios |
| **Ligbox Mail Business** | 25 contas | **R$ 549/mês** = **R$ 22/conta** | 30 GB + 200 GB NC | Files incluído | **Sim** |
**Posicionamento Ligbox:**
- **2045% abaixo** do Google/Microsoft por utilizador em bundles PME
- **Mais storage mail + files** que Zoho no mesmo tier
- **EasyDMARC incluído** (valor ~R$ 200/mês separado) — diferencial entregabilidade
- **Domínio próprio + dados na infra Ligbox** (soberania BR/EU)
- **Gerente de domínio** controla tudo — sem licença Microsoft por utilizador ocioso
### 1.4 Fórmula de preço — bundle custom
```
preço_mensal =
base_fee # R$ 99 (infra + EasyDMARC pool)
+ (seats × mail_gb × R$ 0,35) # mail hot tier
+ (seats × files_gb × R$ 0,08) # nextcloud warm tier
+ addon_subdomain # R$ 0 (incluído) ou R$ 29 extra
```
**Exemplo:** 10 seats · 50 GB mail · 500 GB files:
```
99 + (10 × 50 × 0,35) + (10 × 500 × 0,08)
= 99 + 175 + 400 = R$ 674/mês
```
**Desconto anual:** 15% (alinhado Google/Zoho).
---
## 2. O que cada bundle inclui (feature matrix)
### 2.1 Email (Carbonio VM112)
| Feature | Starter | Business | Enterprise | Custom |
|---------|---------|----------|------------|--------|
| Webmail HTTPS | ✅ | ✅ | ✅ | ✅ |
| IMAP / SMTP / ActiveSync | ✅ | ✅ | ✅ | ✅ |
| Calendário / Contactos | ✅ | ✅ | ✅ | ✅ |
| Alias / lista / forward | ✅ | ✅ | ✅ | ✅ |
| Quota por caixa | 20 GB | 30 GB | 50 GB | 2050 |
| Anti-spam / anti-virus | ✅ | ✅ | ✅ | ✅ |
| Domain Admin (gerente) | ✅ | ✅ | ✅ | ✅ |
### 2.2 Nextcloud (VM116)
| Feature | Todos os bundles |
|---------|------------------|
| URL `files.{dominio}` | ✅ **sempre** (Files obrigatório no bundle) |
| Sync desktop / mobile | ✅ |
| Partilha links + password | ✅ |
| Quota por utilizador | Configurável 100500 GB |
| **App Mail (IMAP → Carbonio)** | **⚙️ Opcional por domínio** — gerente activa/desactiva no `/admin` |
| OnlyOffice / Collabora | Fase 2 |
| Versionamento ficheiros | ✅ |
#### 2.2.1 Nextcloud Mail — opcional por domínio (decisão Roger 2026-06-21)
**Default:** **desactivado** em domínios novos. Webmail Carbonio (`mail.{dominio}`) é sempre a interface email principal.
| Estado | O que o utilizador vê |
|--------|------------------------|
| **Mail OFF** (default) | `mail.{dom}` webmail Carbonio + `files.{dom}` Nextcloud Files — apps separadas |
| **Mail ON** | Mail app pré-configurado no Nextcloud (IMAP→Carbonio) + Files na mesma UI |
**Quem controla:** Gerente de Domínio em `onboard.ligbox.com.br/admin` → secção **Nextcloud / Files** → toggle **«Activar Mail no Nextcloud»**.
**Comportamento técnico:**
| Acção gerente | Backend |
|---------------|---------|
| Activar Mail domínio | `PATCH bundle_entitlements` + flag NC tenant |
| Desactivar Mail domínio | Remove contas Mail app (mantém Files + Carbonio) |
| Criar conta email (Mail ON) | OCS user + pré-config IMAP SMTP (Spec 034 NC-3) |
| Criar conta email (Mail OFF) | OCS user Files only — sem Mail app |
**Carbonio permanece autoridade** — MX, SMTP, quotas mail, OOO, assinatura. Nextcloud Mail é **cliente IMAP opcional**, nunca substituto do servidor.
**Motivo:** equipas só-email usam webmail/Outlook; equipas Files+email escolhem Mail unificado — sem impor carga IMAP extra na VM112 a todos.
### 2.3 EasyDMARC (certificação email Ligbox)
Ligbox opera os servidores de email — **EasyDMARC é incluído no bundle** como garantia de entregabilidade:
| Capacidade | Incluído no bundle |
|------------|-------------------|
| Monitorização DMARC | ✅ Relatórios agregados |
| SPF lookup / validação | ✅ |
| DKIM alinhamento | ✅ Wizard + agente A3 Desk |
| DMARC policy roadmap | ✅ p=none → quarantine → reject |
| EasySPF (flatten) | Business+ |
| Alertas falha autenticação | ✅ email gerente + Desk |
| Badge «Ligbox Certified Mail» | Fase 2 — selo no webmail |
**Modelo operacional:** conta EasyDMARC **MSP Ligbox** (pool) — 1 domínio tenant = 1 slot no pool. Custo interno ~USD 48/domínio/mês repartido no bundle.
### 2.4 Subdomínio
- **Incluído:** 1 subdomínio DNS gerido (ex.: `intranet.empresa.com.br` → site ou redirect)
- Se bundle incluir **Ligbox Site CMS** (Spec 024): subdomínio aponta OpenPanel hosting
- Email bundle **sem site:** subdomínio pode ser CNAME para landing ou Nextcloud
---
## 3. Quem acede a quê (RBAC cliente)
```
┌─────────────────────────────────────┐
│ DOMÍNIO cliente.com.br │
└─────────────────────────────────────┘
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
GERENTE DE DOMÍNIO UTILIZADOR EMAIL VISITANTE
admin@cliente.com.br joao@cliente.com.br (público)
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Painel Admin │ │ Webmail │
│ (§6) │ │ mail.dom │
│ │ │ Nextcloud │
│ • contas │ │ files.dom │
│ • quotas │ │ (sem admin) │
│ • nextcloud │ └──────────────┘
│ • DNS/DMARC │ ← Spec 037-DNS-VIEWER (read-only + links)
│ • faturação │
└──────────────┘
```
| Actor | Login | Painel admin? | Benefícios |
|-------|-------|---------------|------------|
| **Gerente de Domínio** | FOSS cliente → «Gerir domínio» OU `onboard.ligbox.com.br/admin` | **Sim** | Cria contas, quotas, Nextcloud, vê DMARC |
| **Utilizador email** | `mail.{dom}` webmail · app Nextcloud | **Não** | Email + ficheiros pessoais |
| **Staff Ligbox** | Desk VM122 | Sim (RBAC Spec 027) | Suporte, override, billing |
**Regra Roger:** painel administrativo **≠** webmail. Apenas **1+ gerentes** por domínio (configurável, default 1).
---
## 4. Arquitectura FOSS + OpenPanel + Mail
```mermaid
flowchart TB
subgraph Cliente
GM[Gerente Domínio]
U[Users email]
end
subgraph VM123["VM123 — Finance"]
FOSS[FOSSBilling<br/>catálogo + pedidos]
GW[Gateway pagamento<br/>Boleto + PIX QR]
OP[OpenPanel<br/>provision backend]
BR[Bridge :18087]
end
subgraph VM122["VM122 — Desk"]
DS[API orquestração<br/>FOSS ↔ Wizard ↔ NC]
end
subgraph VM112["VM112 — Mail"]
WZ[Wizard API]
DA[Domain Admin SPA<br/>onboard.ligbox.com.br/admin]
CB[Carbonio]
end
subgraph VM116["VM116 — Files"]
NC[Nextcloud Hub]
OCS[OCS Provisioning API]
end
subgraph External
EDM[EasyDMARC API]
CF[Cloudflare DNS]
end
GM -->|compra bundle| FOSS
FOSS -->|boleto / PIX QR| GW
GW -->|pagamento confirmado| FOSS
FOSS -->|order paid| BR
BR -->|provision hub| OP
FOSS -->|webhook| DS
DS -->|mail-bundle| WZ
WZ -->|domínio + contas| CB
WZ -->|provision OCS| OCS
OCS --> NC
WZ -->|registar domínio| EDM
GM -->|login único| DA
DA -->|CRUD contas + quotas NC| WZ
DA -->|resumo plano / faturas| DS
DS -->|proxy FOSS| FOSS
U --> CB
U --> NC
WZ --> CF
```
### 4.1 Papel de cada sistema (ecossistema completo — Roger 2026-06-21)
**Face visível ao gerente:** uma só página — `https://onboard.ligbox.com.br/admin`
**Por trás (invisível ao cliente):**
| Sistema | VM | Papel |
|---------|-----|-------|
| **FOSSBilling** | 123 | Venda, planos, faturas, área «Meus serviços», pedidos |
| **Gateway pagamento** | 123 | **Boleto bancário + PIX QR Code** — geração, confirmação, baixa automática no FOSS |
| **OpenPanel** | 123 | Provisionamento **backend** (hub) — **sem UI separada** para mail |
| **Desk API** | 122 | Orquestração FOSS ↔ Wizard ↔ Nextcloud; webhooks; proxy billing |
| **Wizard VM112** | 112 | Motor do `/admin` — Carbonio CRUD, limites bundle, DNS |
| **Nextcloud Hub** | 116 | Files por conta — **gerido pelo gerente no `/admin`** (quotas, activar/desactivar) |
| **EasyDMARC** | ext. | Certificação SPF/DKIM/DMARC |
| **Carbonio** | 112 | Motor email (webmail, IMAP) — users finais |
### 4.2 Gateway pagamento — Boleto + PIX QR (obrigatório Spec 035)
Roger: **não esquecer** — cliente Ligbox precisa pagar com meios BR standard.
| Capacidade | Onde | UX cliente |
|------------|------|------------|
| **Boleto bancário** | Gateway → módulo FOSS | PDF / linha digitável na área FOSS + email |
| **PIX QR Code** | Gateway → módulo FOSS | QR na fatura + copia-e-cola |
| **Cartão** (opcional fase 2) | Gateway | Checkout FOSS |
| **Confirmação pagamento** | Webhook gateway → FOSS → Desk → Wizard | Activa bundle + limites |
**Gateway candidatos:** ASAAS · Iugu · Mercado Pago (decisão §14)
**Fluxo:**
```
Cliente escolhe plano FOSS
→ FOSS gera fatura
→ Gateway emite boleto + PIX QR
→ Cliente paga
→ Webhook confirma
→ FOSS order = active
→ Desk provisiona mail + Nextcloud
→ Gerente entra /admin
```
**No `/admin` (gerente):** resumo «Plano activo / aguardando pagamento» + link «Ver boleto / PIX» (embed FOSS ou API Desk — **não** redireccionar para setup noutro portal).
### 4.3 Nextcloud — visão admin no `/admin` (obrigatório Spec 035)
Roger: gestão de contas Files **no mesmo painel** — gerente **não** abre consola Nextcloud separada.
| Acção gerente | UI `/admin` | Backend |
|---------------|---------------|---------|
| Criar conta email | modal contas | Carbonio + **auto-provision NC** |
| Quota Files por user | slider / dropdown | Nextcloud **OCS API** VM116 |
| Activar/desactivar Files | toggle | OCS disable user |
| Ver uso disco domínio | card resumo | OCS quota report |
| Atalho Files user | botão «Abrir Files» | SSO token → `files.{dominio}` |
**Regra:** cada `@dominio` com mail activo → conta Nextcloud espelhada (Spec 034 OCS).
**Utilizador final:** só app/web `files.{dominio}`**sem** admin NC.
### 4.4 Papel resumido (versão curta)
| Sistema | Papel no bundle email |
|---------|----------------------|
| **FOSSBilling** | Venda, upgrade/downgrade, fatura, área «Meus serviços» |
| **Gateway** | Boleto + PIX QR — cobrança Brasil |
| **OpenPanel** | Backend provision — **invisível** ao gerente |
| **Wizard + `/admin`** | **Cockpit único** — mail + Nextcloud + resumo plano |
| **Desk** | APIs orquestração (FOSS ↔ Wizard ↔ NC) |
| **Nextcloud VM116** | Storage Files — admin via `/admin`, uso via `files.{dom}` |
**Importante:** OpenPanel **não substitui** Carbonio nem Nextcloud admin. FOSS **não substitui** `/admin` para gestão de contas.
---
## 5. Configuração FOSSBilling
### 5.1 Produtos a criar (Admin → Products)
#### Produto 13: bundles fixos
| Campo FOSS | Starter | Business | Enterprise |
|------------|---------|----------|------------|
| `title` | Ligbox Mail Starter | Ligbox Mail Business | Ligbox Mail Enterprise |
| `slug` | ligbox-mail-starter | ligbox-mail-business | ligbox-mail-enterprise |
| `type` | hosting | hosting | hosting |
| `pricing` | monthly R$ 249 | monthly R$ 549 | monthly R$ 999 |
| `setup` | R$ 0 | R$ 0 | R$ 0 |
| `plugin` | OpenPanel | OpenPanel | OpenPanel |
| `plugin_config.plan` | ligbox-mail-starter | ligbox-mail-business | ligbox-mail-enterprise |
**Custom fields no pedido (obrigatórios):**
| Campo | Tipo | Exemplo |
|-------|------|---------|
| `domain` | text | `empresa.com.br` |
| `manager_email` | email | `admin@empresa.com.br` |
| `manager_name` | text | João Silva |
#### Produto 4: bundle custom (configurável)
| Campo | Valor |
|-------|-------|
| `slug` | ligbox-mail-custom |
| `type` | hosting + **config options** |
**Config options FOSS:**
| Option ID | Nome | Tipo | Valores | Preço unitário |
|-----------|------|------|---------|----------------|
| `seats` | Nº contas email | dropdown | 10,25,30,40,50 | ver §1.4 |
| `mail_gb` | GB mail/conta | dropdown | 20,30,40,50 | R$ 0,35/GB/seat |
| `files_gb` | GB Nextcloud/conta | dropdown | 100,200,300,400,500 | R$ 0,08/GB/seat |
### 5.2 Webhook pós-pagamento
```
POST https://desk.ligbox.com.br/api/v1/billing/webhook/foss/order-activated
{
"order_id": 123,
"product_slug": "ligbox-mail-business",
"client_email": "admin@empresa.com.br",
"domain": "empresa.com.br",
"config": {
"seats": 25,
"mail_gb": 30,
"files_gb": 200
}
}
```
Desk → Wizard `POST /api/internal/provision/mail-bundle`
---
## 6. Configuração OpenPanel
### 6.1 Planos OpenPanel (OpenAdmin → Plans)
Criar planos espelhando FOSS (bridge `plan_name`):
| plan_name | Domínios | Contas OP | Notas |
|-----------|----------|-----------|-------|
| `ligbox-mail-starter` | 1 | 1 | Hub gerente only |
| `ligbox-mail-business` | 1 | 1 | idem |
| `ligbox-mail-enterprise` | 1 | 1 | idem |
| `ligbox-mail-custom` | 1 | 1 | quotas via metadata JSON |
| `ligbox-site-cms` | 1 | 1 | **já existe** — add-on site |
**Limites OpenPanel CE:** 1 user OpenPanel = gerente. **Não** criar 1 user OP por caixa email.
### 6.2 Metadata do plano (JSON em custom field bridge)
```json
{
"bundle_type": "ligbox_mail",
"max_seats": 25,
"mail_gb_per_seat": 30,
"files_gb_per_seat": 200,
"easydmarc": true,
"wizard_domain": "empresa.com.br"
}
```
### 6.3 Página custom «Ligbox Mail» no OpenPanel (Fase 1)
**Objectivo:** gerente loga em `https://openpanel.ligbox.com.br` e vê:
```
┌─────────────────────────────────────────────────────────┐
│ Ligbox Mail Console — empresa.com.br │
├─────────────────────────────────────────────────────────┤
│ Plano: Business · 12/25 contas · DMARC: ✅ │
│ │
│ [ Gerir contas email ] → autologin Domain Admin │
│ [ Abrir webmail ] → mail.empresa.com.br │
│ [ Abrir Files ] → files.empresa.com.br │
│ [ Certificação DMARC ] → modal EasyDMARC status │
│ [ Faturação ] → financeiro.ligbox.com.br │
└─────────────────────────────────────────────────────────┘
```
**Implementação (escolher 1):**
| Opção | Esforço | Recomendação |
|-------|---------|--------------|
| **A — Deep-link Desk** | Baixo | OpenPanel mostra links estáticos + token SSO |
| **B — iframe Domain Admin** | Médio | SPA wizard embutida no OP |
| **C — Módulo OP custom** | Alto | Plugin PHP/JS nativo OpenPanel |
**Recomendação Spec 035:** **Opção A (MVP)****Opção B (Fase 2)**.
---
## 7. Login do Gerente de Domínio — fluxo completo
### 7.1 Primeiro acesso (pós-compra)
```
1. Cliente paga bundle no FOSSBilling (financeiro.ligbox.com.br)
2. FOSS → Bridge OpenPanel: cria user `empresa-hub` + plan ligbox-mail-*
3. FOSS → email boas-vindas com:
• Link FOSS cliente: financeiro.ligbox.com.br/login
• Link OpenPanel hub: openpanel.ligbox.com.br
• Credenciais temporárias (forçar troca)
4. Webhook → Wizard:
• Cria domínio Carbonio
• Cria admin@empresa.com.br (gerente)
• Regista EasyDMARC
• Provisiona Nextcloud tenant
• Grava bundle limits em DB wizard
5. Gerente acede OpenPanel OU onboard.ligbox.com.br/admin
6. Primeiro login → wizard onboarding DNS (MX, SPF, DMARC)
```
### 7.2 Login recorrente (3 caminhos equivalentes)
| Caminho | URL | Mecanismo |
|---------|-----|-----------|
| **FOSS → Gerir** | `financeiro.ligbox.com.br/client/service/{id}` | Botão «Gerir Mail» → SSO token → OpenPanel ou Domain Admin |
| **OpenPanel hub** | `openpanel.ligbox.com.br` | Login OP → página Ligbox Mail Console |
| **Directo Domain Admin** | `onboard.ligbox.com.br/admin` | Login `admin@{dom}` + senha Carbonio/wizard |
### 7.3 SSO token (Desk emite)
```http
POST /api/v1/domain-admin/sso-token
Authorization: Bearer <session gerente>
Body: { "domain": "empresa.com.br" }
Response:
{
"redirect_url": "https://onboard.ligbox.com.br/admin?sso=eyJ...",
"expires_in": 300
}
```
**Validação:** token assinado HMAC, single-use, domínio no claim = domínio do gerente.
### 7.4 O que o gerente faz no painel (Domain Admin SPA)
| Acção | Onde executa | Propaga para |
|-------|--------------|--------------|
| Criar conta `vendas@` | Domain Admin | Carbonio + Nextcloud (auto) |
| Alterar quota mail 30→40 GB | Domain Admin | Carbonio zmprov |
| Alterar quota Files 200→500 GB | Domain Admin | Nextcloud OCS API |
| Suspender conta | Domain Admin | Carbonio + NC disable |
| Ver status DMARC | Domain Admin modal | EasyDMARC API |
| Adicionar alias | Domain Admin | Carbonio |
| Reset senha user | Domain Admin | Carbonio (+ sync NC fase 2) |
**Limite:** gerente **não pode** exceder `max_seats` / quotas do bundle FOSS — wizard valida contra `bundle_entitlements` table.
### 7.5 Upgrade de plano
```
Gerente → FOSS «Upgrade para Enterprise»
→ FOSS recalcula preço
→ Pagamento confirmado
→ Webhook atualiza bundle_entitlements
→ Domain Admin reflecte novos limites (25→50 contas)
```
---
## 8. Provisionamento técnico (ordem)
| Step | Actor | Acção |
|------|-------|-------|
| 1 | FOSS | Order `active` |
| 2 | Bridge | `POST /api/users` plan=ligbox-mail-* domain=X |
| 3 | Desk webhook | Recebe order, valida slug |
| 4 | Wizard | `POST /api/internal/provision/mail-bundle` |
| 5 | Wizard | Carbonio: `createDomain`, `createAccount admin@` |
| 6 | Wizard | Nextcloud: OCS create tenant + admin |
| 7 | Wizard | EasyDMARC: register domain (API) |
| 8 | Wizard | Cloudflare: MX, SPF, DMARC records |
| 9 | Traefik CT114 | SNI `mail.{dom}` + `files.{dom}` |
| 10 | Desk | `billing_accounts.plan_code` = slug · state = active |
| 11 | FOSS | Email «Domínio pronto» + links login |
---
## 9. Entitlements (tabela wizard — nova)
```sql
CREATE TABLE bundle_entitlements (
id INTEGER PRIMARY KEY,
domain TEXT NOT NULL UNIQUE,
foss_order_id INTEGER,
product_slug TEXT NOT NULL,
max_seats INTEGER NOT NULL,
mail_gb_per_seat INTEGER NOT NULL,
files_gb_per_seat INTEGER NOT NULL,
seats_used INTEGER DEFAULT 1,
easydmarc_enabled BOOLEAN DEFAULT 1,
nextcloud_mail_enabled BOOLEAN DEFAULT 0,
subdomain TEXT,
dns_mode TEXT, -- ligbox_cf_* | byo_cf | external | openpanel_bind (Spec 037)
dns_provider TEXT, -- cf_ligbox | cf_byo | openpanel_bind | registrar
cf_zone_id TEXT,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
Wizard **rejeita** `createAccount` se `seats_used >= max_seats`.
---
## 9.1 DNS Viewer — visibilidade DNS (Spec 037-DNS-VIEWER)
Roger (2026-06-25): gerente e staff precisam **ver todos os apontamentos** sem aceder a Cloudflare/OpenPanel directamente.
| Actor | Onde vê | Pode editar? |
|-------|---------|--------------|
| **Gerente domínio** | Console `/admin` → secção Domínio & DNS | ❌ na Console — link externo se BYO |
| **Staff Ligbox** | Desk Overview + Serviços IaaS | ❌ — deep-link CF/OP (027) |
| **Cliente wizard** | Passo DNS onboarding | ❌ — instructions only |
**Regra de exibição (037):**
| `dns_mode` no entitlements | UI mostra |
|----------------------------|-----------|
| `ligbox_cf_*` | Registos **aplicados/planeados** Ligbox + NS Cloudflare |
| `byo_cf` / `external` / `registrar` | DNS **actual** (público) + instruções |
Documento técnico: [037 dns-viewer.md](../037-dns-multi-cloudflare-orchestration/dns-viewer.md)
UI gerente: [domain-manager-console-ui.md §5.5](./domain-manager-console-ui.md#55-domínio--dns-dns-viewer--spec-037-dns-viewer)
---
## 10. Add-ons FOSS (venda separada)
| Slug | Nome | Preço sugerido |
|------|------|----------------|
| `ligbox-mail-seat-extra` | +1 conta email | R$ 19/mês |
| `ligbox-mail-storage-mail` | +10 GB mail (domínio) | R$ 29/mês |
| `ligbox-mail-storage-files` | +50 GB Nextcloud (domínio) | R$ 39/mês |
| `ligbox-site-cms` | Site CMS OpenPanel | **já existe** (grátis hoje) |
| `ligbox-mail-easydmarc-pro` | DMARC enforcement + relatórios 1 ano | R$ 49/mês |
---
## 11. Fases de implementação
| Fase | Entregável | Spec deps |
|------|------------|-----------|
| **0** | Produtos FOSS + planos OP + preços publicados | 024, 028 |
| **1** | Webhook provision mail-bundle + entitlements | 034 Fase 0 |
| **2** | Domain Admin: quotas NC + seat limits + **DNS Viewer `/admin`** | 034 Fase 1 · **037 dns-viewer** |
| **3** | OpenPanel Ligbox Mail Console (links SSO) | 028, 035 |
| **4** | EasyDMARC API integrada + badge | Agent A3 |
| **5** | Configurador custom FOSS + upgrade flow | 023 |
---
## 12. Critérios de aceite
1. Cliente compra **Ligbox Mail Business** no FOSS → em ≤15 min domínio activo com admin@ funcional.
2. Gerente loga OpenPanel hub → vê resumo bundle + link «Gerir contas» funcional.
3. Gerente cria 3 contas email → Nextcloud provisionado automaticamente para cada.
4. Utilizador `vendas@` acede webmail + files — **sem** acesso admin.
5. Tentativa criar conta #26 em plano 25 → erro claro «Upgrade plano».
6. EasyDMARC mostra SPF+DKIM+DMARC ✅ no painel gerente.
7. Upgrade FOSS Starter→Business → limites actualizados sem re-provision completo.
8. Gerente abre **Domínio & DNS** → vê tabela read-only completa (037-DNS-VIEWER); domínio Ligbox mostra applied/planned; externo mostra estado público actual.
---
## 13. Documentos relacionados
| Doc | Path |
|-----|------|
| Nextcloud integração | `specs/034-nextcloud-carbonio-vm112-integration/spec.md` |
| FOSS + OpenPanel | `specs/024-openpanel-fossbilling/spec.md` |
| Bridge API | `specs/028-openpanel-ce-ligbox-reengineering/contracts/foss-bridge-api.md` |
| Domain Admin | `specs/010-admin-domain-validation/spec.md` |
| Nextcloud OCS API | `specs/034-.../contracts/nextcloud-provisioning-api.md` |
| Tasks 035 | `specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md` |
| FOSS product JSON | `specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md` |
| UI Shell unificada | [ligbox-console-shell.md](./ligbox-console-shell.md) |
| UI Gerente domínio | [domain-manager-console-ui.md](./domain-manager-console-ui.md) |
| UI Admin Ligbox (sistema) | [ligbox-system-admin-ui.md](./ligbox-system-admin-ui.md) |
| UI Utilizador email (Fase B) | [user-self-service-ui.md](./user-self-service-ui.md) |
| **DNS Viewer read-only** | [037 dns-viewer.md](../037-dns-multi-cloudflare-orchestration/dns-viewer.md) |
---
## 14. Decisões
### 14.1 Fixadas (Roger 2026-06-21)
| # | Decisão | Valor |
|---|---------|-------|
| U1 | Shell visual partilhada | Design system React único |
| U2 | URL marca | **`console.ligbox.com.br`** — login detecta role |
| U3 | Ops Wazuh | **Mesmo shell**`/ops` (Spec 019) |
| U4 | Tom visual | **Caloroso BR** — banco digital |
| U5 | Prioridade UI | **Gerente `/admin` primeiro** |
| U6 | Nextcloud Mail | **Opcional por domínio** — toggle `/admin` |
| U7 | Padrões UX Spec **030** | Status bar, 3 colunas, cards, context panel |
| U8 | DNS domínio | **Read-only** — Spec **037-DNS-VIEWER**; Ligbox=planned/applied, externo=actual |
Ver: [ligbox-console-shell.md](./ligbox-console-shell.md)
### 14.2 Pendentes (Roger)
| # | Pergunta | Opções |
|---|----------|--------|
| 1 | Preços finais Starter/Business/Enterprise | **Aprovar na Admin Ligbox** (035-C) — não no FOSS |
| 2 | OpenPanel hub vs só Domain Admin | Backend only ✅ — UI só Console |
| 3 | Senha única Carbonio+Nextcloud Fase 1? | Sim (sync wizard) vs convite separado |
| 4 | EasyDMARC pool MSP | Confirmar plano EasyDMARC actual Ligbox |
| 5 | Gateway pagamento | **ASAAS vs Iugu** — boleto + PIX QR obrigatório Fase 4 |

View file

@ -0,0 +1,92 @@
# Spec 035 — Tasks
**Status:** Draft · **Início:** 2026-06-21
---
## Fase 0 — Catálogo comercial (FOSS + OpenPanel)
- [ ] Roger aprovar preços §1.1 — **via Admin Ligbox** (035-C), não FOSS directo
- [ ] Criar produtos FOSS: starter, business, enterprise, custom
- [ ] Criar planos OpenPanel espelhados (ligbox-mail-*)
- [ ] Config options FOSS para bundle custom (seats, mail_gb, files_gb)
- [ ] Publicar página vendas /signup com comparativo concorrência
- [ ] Documentar em `foss-products.md` IDs reais pós-criação
## Fase 1 — Provisionamento
- [ ] Tabela `bundle_entitlements` no wizard VM112
- [ ] Endpoint `POST /api/internal/provision/mail-bundle`
- [ ] Webhook Desk `foss/order-activated` → wizard
- [ ] Bridge: metadata JSON no user OpenPanel hub
- [ ] Email template FOSS boas-vindas (links OP + Domain Admin)
- [ ] Teste E2E: order Business → admin@ + 1 conta teste
## Fase 2 — Ligbox Console `/admin` gerente (PRIORIDADE UX — Roger 2026-06-21)
Shell: [ligbox-console-shell.md](./ligbox-console-shell.md) · Detalhe: [domain-manager-console-ui.md](./domain-manager-console-ui.md)
- [ ] UX-0: design tokens + AppLayout + login role → `console.ligbox.com.br`
- [ ] Traefik: `console.ligbox.com.br` VM123 + 301 onboard/admin
- [ ] UX-A1: `/admin` Início — cards resumo (tom banco digital BR)
- [ ] UX-A2: `/admin/contas` CRUD inline
- [ ] UX-A3: `/admin/files` quotas + toggle Mail NC
- [ ] UX-A4: `/admin/certificacao` + `/admin/dominio` **DNS Viewer** (037 dns-viewer.md)
- [x] `/admin/dominio` V3 deploy VM123 2026-06-25
- [ ] UX-A4b: `/admin/plano` boleto/PIX
- [ ] SSO FOSS → `/admin?sso=TOKEN`
- [ ] API Desk `/api/v1/domain-console/*`
## Fase 2b — OpenPanel (backend only)
- [ ] Bridge metadata JSON no user hub (sem UI gerente)
## Fase C — Admin Ligbox staff (`/comercial` + `/ops`) — depois UX-A
Ver: [ligbox-system-admin-ui.md](./ligbox-system-admin-ui.md) · Shell: [ligbox-console-shell.md](./ligbox-console-shell.md)
- [ ] UX-B: `/comercial` — fila preços, catálogo, clientes, gateway
- [ ] UX-C: `/ops` — Spec 019 chamados no mesmo shell
- [ ] RBAC roles comercial/ops (Spec 027)
- [ ] Impersonate gerente → `/admin`
## Fase 3 — Self-service utilizador email (Fase B — depois)
Ver placeholder: [user-self-service-ui.md](./user-self-service-ui.md)
- [ ] Estudo redirects, OOO, assinaturas, calendário por user
- [ ] Wireframe `/me` ou `mail.{dom}/settings`
- [ ] Decidir Carbonio nativo vs SPA Ligbox
## Fase 3 — Nextcloud + EasyDMARC
- [ ] VM116 provisionada (Spec 034 Fase 0)
- [ ] Auto-provision NC Files em cada createAccount
- [ ] Toggle Mail Nextcloud por domínio no `/admin` (Spec 035 §2.2.1)
- [ ] EasyDMARC register domain automático no wizard
- [ ] Agent A3 Desk: alertas DMARC fail
## Fase 4 — Gateway pagamento + comercial avançado
- [ ] Escolher gateway: ASAAS vs Iugu (Roger)
- [ ] Módulo FOSS: boleto bancário (PDF + linha digitável)
- [ ] Módulo FOSS: PIX QR Code + copia-e-cola
- [ ] Webhook pagamento confirmado → Desk → wizard provision
- [ ] Card «Pagamento» no `/admin` — status + link boleto/PIX (embed FOSS API)
- [ ] Upgrade/downgrade plano FOSS → update entitlements
- [ ] Add-ons: seat extra, storage mail/files
- [ ] Odoo subscription mirror (Spec 023 phase 2)
## Documentação
- [x] Spec 035 `spec.md`
- [x] `tasks.md`
- [x] `foss-products.md` (template)
- [x] `mockups/domain-manager-sandbox.html` — UI sandbox (Mock + Live create-only)
- [x] API Desk `domain_console_sandbox*.py` — create-only produção
- [ ] Deploy API sandbox no VM122 + teste cenário real
- [ ] Actualizar Spec 034 § comercial → link 035
- [ ] Actualizar `docs/vms/README.md`
- [ ] Publicar Spec Hub Portal
- [x] Spec 037-DNS-VIEWER `dns-viewer.md` + §5.5 domain-manager actualizado
- [ ] Implementar `GET /api/v1/dns/viewer/{domain}` (Desk + Console)

View file

@ -0,0 +1,75 @@
# Spec 035-B — Área Utilizador de Email (Fase B — placeholder)
**Criado:** 2026-06-21
**Solicitado por:** Roger
**Status:** 📋 **A estudar** — não iniciar antes de Fase A concluída
**Depende de:** `domain-manager-console-ui.md` (Fase A)
---
## Objetivo futuro
Cada utilizador com caixa `@dominio` gere **as suas próprias preferências** — sem acesso à Área Gerente de Domínio.
Roger indicou estudar **depois**:
- Redirects / encaminhamento de email
- Out of office (fora do escritório / férias)
- Assinaturas de email
- Calendário pessoal
- (outras prefs Carbonio webmail)
---
## Princípio Roger (2026-06-21)
**Uma página agregada** — todos os setups de **utilizador** de **todas** as ferramentas, no mesmo sítio:
| Ferramenta | Setup user agregado aqui |
|------------|--------------------------|
| Carbonio | senha, OOO, assinatura, redirects, quota uso |
| Nextcloud | prefs Files pessoais, quota uso, partilhas |
| (futuro) | notificações, 2FA |
O gerente configura **domínio** em `/admin`; o utilizador configura **a si** em `/me`**sem** abrir consolas nativas.
---
## Opções arquitectura (decisão pendente)
| Opção | Prós | Contras |
|-------|------|---------|
| **A — Carbonio webmail nativo** | Já existe vacation, filters, signature | UX inconsistente com Ligbox |
| **B — SPA Ligbox `/me`** | Brand unificado, mobile | Desenvolver + manter APIs Carbonio |
| **C — Híbrido** | Assinatura/OOO na SPA; calendário link CalDAV | Dois sítios |
**Recomendação draft:** Opção **C** — MVP redirects + OOO + assinatura na SPA; calendário link para Carbonio/CalDAV.
---
## Funcionalidades candidatas
| Feature | API Carbonio | Prioridade estimada |
|---------|--------------|---------------------|
| Alterar senha | zmprov / soap | P0 |
| Vacation / OOO | prefs | P0 |
| Assinatura HTML | prefs | P1 |
| Redirect / forward | zimbraMailForwardingAddress | P1 |
| Filtros regras | sieve (4190 **indisponível** hoje) | P2 bloqueado |
| Calendário | CalDAV | P2 link externo |
| Quota uso pessoal | read-only | P1 |
**Bloqueio conhecido:** ManageSieve (4190) não disponível no Carbonio CE — filtros avançados requerem workaround Spec 034.
---
## Próximo passo
Quando Roger autorizar Fase B:
1. Wireframe `user-self-service-ui.md` v1 completo
2. Decidir URL e login (webmail session vs standalone)
3. Mapear APIs Carbonio por feature
4. Spec 036 ou expandir 035-B
**Até lá:** nenhum código — foco total na **Área Gerente de Domínio** (Fase A).

View file

@ -0,0 +1,167 @@
# Anexo 037-D — Cloudflare Agents SDK × Orquestração Ligbox
**Parent:** [spec.md](./spec.md) · Spec **037**
**Relacionado:** Spec **029** (agentic-ops-runbooks) · Spec **030** (agentic-ops-ui)
**Roger · 2026-06-22** · Status: 📋 Arquitectura alvo
---
## Contexto
O [Cloudflare Agents SDK](https://developers.cloudflare.com/agents/) é **realidade hoje**:
| Padrão | Uso Ligbox |
|--------|------------|
| **Multi-agent** | Agentes especializados (identidade, DNS, mail) colaboram no onboarding |
| **Human-in-the-loop** | Decisões críticas: BYO vs Ligbox CF, handoff gestor, purge |
| **Addressable agents** | WebSocket wizard ↔ agente; Desk ops ↔ thread A6 Copiloto |
**Infra:** cada agente = **Durable Object** stateful — hiberna ocioso, acorda sob demanda, storage próprio, MCP para APIs externas (VM112, pfSense, Carbonio).
---
## Duas camadas agenticas Ligbox
```mermaid
flowchart TB
subgraph edge [Cloudflare Edge — Agents SDK]
O[Orquestrador Onboard]
I[Agente Identidade]
D[Agente DNS/CF]
H[Agente Handoff]
end
subgraph vm122 [VM122 Ops Desk — Spec 029]
A0[Maestro A0]
A6[Copiloto A6]
A2[Trilho A2]
end
subgraph vm112 [VM112 Wizard API]
W[FastAPI onboarding]
C[Carbonio zmprov]
CF[Cloudflare API]
end
O --> I
O --> D
O --> H
I -->|MCP / webhook| W
D -->|MCP / webhook| W
H -->|MCP / webhook| W
W --> C
W --> CF
O -->|eventos funil 004| A0
A6 -->|human-in-the-loop| O
A2 -->|validação infra| D
```
| Camada | Onde corre | Papel |
|--------|------------|-------|
| **Edge (CF Agents)** | Workers + Durable Objects | Orquestração **por cliente/domínio** em tempo real no wizard |
| **Ops (Spec 029)** | VM122 + Ollama | Vigilância 24/7, inbox humano, runbooks, Copiloto |
Não substituem-se — **complementam**.
---
## Roster onboarding (novos agentes edge)
| Agente edge | Codename | Responsabilidade | Spec 037 |
|-------------|----------|------------------|----------|
| **Orquestrador** | `onboard-maestro` | Triagem: Ligbox CF / BYO / registrador; estado do funil | Fluxo decisão |
| **Identidade** | `proj-mail` | Cria `proj_{id}@ligbox.com.br`, vault senha, entrega wizard | [037-C](./project-email-identity.md) |
| **DNS** | `cf-zone` | `provision-zone`, `apply`, `dns/verify`, multi-conta ligit/itecnologys/ibytera | Fase 1 actual |
| **Handoff** | `cf-handoff` | Convite `admin@dominio` na CF após go-live | [037-B](./client-cf-account-lifecycle.md) |
Cada agente edge tem:
- **Durable Object ID** = `project:{domain}` ou `session:{onboarding_session_id}`
- **Memória** = `project_id`, `ligbox_project_email`, `cf_account_id`, `dns_mode`, passos concluídos
- **MCP tools** = wrappers HTTP para VM112 (`/api/onboarding/...`)
---
## Mapeamento → Roster Spec 029 (A0A7)
| Agente edge | Delega / reporta a | Human-in-the-loop |
|-------------|-------------------|-------------------|
| `onboard-maestro` | **A0 Maestro** (tick + audit) | `agentic_operator` se BYO token inválido 3× |
| `proj-mail` | **A2 Trilho** (infra mail) | Ops se Carbonio falhar |
| `cf-zone` | **A3 Carta** (deliverability) | Cliente escolhe caminho DNS |
| `cf-handoff` | **A6 Copiloto** (thread cliente) | Confirmação antes de convite gestor CF |
| Falha crítica | **A7 Remediador** | Sempre humano antes de acção |
Role Desk já existente: `agentic_operator` (Spec 027/029).
---
## Human-in-the-loop — pontos obrigatórios
1. **Escolha DNS** — BYO vs Ligbox vs registrador (wizard UI).
2. **Reveal senha** `proj_*` — uma vez; agente pausa até `mark_password_delivered`.
3. **Handoff CF** — convite `admin@cliente` só após `dns/verify` + `account/create` OK.
4. **Purge / delete zona** — nunca automático (Spec 017).
Padrão SDK: agente **planeja** → persiste estado no DO → **aguarda** webhook/UI → retoma.
---
## Conectividade
| Canal | Uso |
|-------|-----|
| **WebSocket** (wizard) | Stream de passos, logs activity, «agente a trabalhar» |
| **MCP → VM112** | `provision-email`, `provision-zone`, `apply`, `account/create` |
| **MCP → CF API** | Nativo no Worker (token scoped por conta cliente) |
| **Webhook → VM122** | Funil 004: `onboard.dns.applied`, `project.email.created` |
| **Scheduling** | Retry DNS verify, lembrete NS registrador D+1 |
---
## Fases de implementação
### Fase A — Hoje (sem Workers)
- VM112 FastAPI + wizard React (Spec 037 Fase 1) ✅
- `project_identity.py` scaffold (037-C)
- Agentes 029 observam via webhooks
### Fase B — Worker orquestrador único
- 1 Durable Object `OnboardSession` por sessão wizard
- Delega a VM112 via HTTP (sem multi-agent ainda)
- WebSocket no wizard
### Fase C — Multi-agent edge
- 4 agentes especializados + MCP
- Estado partilhado via `onboard-maestro` (coordenação)
- Human-in-the-loop nos 4 pontos acima
### Fase D — Conta CF dedicada por cliente
- `cf-zone` + Tenant API `POST /accounts`
- Token scoped por `cf_account_id` no DO storage
---
## Porque Cloudflare Agents aqui
| Benefício | Onboarding Ligbox |
|-----------|-------------------|
| Stateful por cliente | Sessão longa (dias até NS propagar) |
| Hibernação | Milhares de onboardings paralelos, custo ~0 entre passos |
| MCP | VM112/pfSense sem expor tokens no browser |
| Edge | Baixa latência wizard público `onboard.ligbox.com.br` |
---
## Critérios de aceitação (Fase C)
1. Wizard conecta WebSocket ao `onboard-maestro` DO.
2. `proj-mail` cria caixa e orquestrador só avança após senha entregue.
3. `cf-zone` completa apply + verify com estado persistido no DO.
4. Eventos chegam ao Maestro A0 no Desk.
5. Handoff CF exige ack humano na inbox Spec 029.
---
## Referências
- [Cloudflare Agents — multi-agent](https://developers.cloudflare.com/agents/)
- [Agents SDK GitHub](https://github.com/cloudflare/agents)
- Ligbox Spec 029 `agents-roster.md` (A0A7)
- Ligbox Spec 037 `project-email-identity.md`

View file

@ -0,0 +1,149 @@
# Anexo 037-B — Conta Cloudflare por cliente (e-mail proj + handoff gestor)
**Parent:** [spec.md](./spec.md) · Spec **037**
**Solicitado por:** Roger · **2026-06-22**
**Status:** 📋 Regra definida — implementação Fase 2
---
## Regra de negócio (Roger)
Novos clientes que escolhem **vir para Cloudflare gerenciada pela Ligbox** recebem uma **conta Cloudflare dedicada** (não zona partilhada nas contas mãe ligit/itecnologys/ibytera a longo prazo).
### Fase A — Onboarding (conta sob controlo Ligbox)
| Campo | Valor |
|-------|--------|
| **E-mail titular / membro inicial** | `proj_{id}@ligbox.com.br` (ex.: `proj_005@ligbox.com.br`) |
| **Nome da conta CF** | Dados do **domínio do cliente** (ex.: `Empresa XYZ — empresa.com.br`) |
| **Zona DNS** | `cliente.com.br` criada **nesta** conta |
| **Gestão** | Ligbox opera via API token / membro admin Ligbox |
O `proj_{id}` vem do **ID de projeto/onboarding** (sequencial ou UUID curto no wizard / Ops Desk).
### Fase B — Handoff (conta estável)
Quando a conta estiver **setada e a funcionar** (DNS verificado + conta email Carbonio criada + infra OK):
1. **Convidar** `admin@{dominio_cliente}` (ou e-mail indicado no wizard) como **gestor** na conta Cloudflare.
2. Role sugerida: **Administrator** (ou custom com Zone DNS + Read mínimo se preferirem).
3. Manter `proj_{id}@ligbox.com.br` como membro técnico Ligbox (não remover até handoff confirmado).
4. Registar em `domain_registry`: `cf_account_id`, `cf_member_client_email`, `handoff_status`.
Opcional futuro: **promover** e-mail do cliente a owner principal e rebaixar `proj_*` a read-only — depende da política Cloudflare Tenant.
---
## Fluxo wizard (alvo Fase 2)
```mermaid
sequenceDiagram
participant W as Wizard VM112
participant CF as Cloudflare API
participant M as Mail ligbox.com.br
participant C as Cliente
W->>W: Gera project_id (ex. 005)
W->>M: Cria proj_005@ligbox.com.br + senha
W->>C: Entrega credenciais projeto (wizard)
W->>CF: POST /accounts (name=cliente.com.br)
Note over CF: Membro inicial proj_005@ligbox.com.br
W->>CF: POST /zones (account_id novo)
W->>CF: upsert MX/SPF/DMARC
W->>C: NS no registrador
C->>C: Propaga DNS
W->>W: dns/verify OK + account/create OK
W->>CF: POST /accounts/{id}/members (admin@cliente.com.br)
CF->>C: Convite gestor
W->>M: Notifica ops + cliente
```
---
## Relação com Fase 1 (actual)
| Fase 1 (hoje) | Fase 2 (regra Roger) |
|---------------|----------------------|
| Zona nova em conta partilhada `ibytera` | Conta CF **nova por cliente** |
| Token único conta mãe | Token scoped por `cf_account_id` cliente |
| Sem `proj_*@ligbox.com.br` | E-mail projeto Ligbox como membro inicial |
| Sem handoff gestor | Convite `admin@dominio` pós-go-live |
**Transição:** Fase 1 mantém-se até Tenant API / tokens prontos; novos clientes premium ou flag `dedicated_cf_account: true` usam Fase 2.
---
## Pré-requisitos técnicos
### 1. E-mails `proj_*@ligbox.com.br`
- O **agente Ligbox cria caixa real** no Carbonio (`ligbox.com.br`) via `project_identity.provision_project_email()`.
- Entrega **senha** ao cliente no wizard (uma vez) — ver [project-email-identity.md](./project-email-identity.md).
- Padrão: `proj_{project_id:03d}@ligbox.com.br`.
- Referenciado em: CF member, `domain_registry`, webhooks DNS, `activity_log`.
### 2. API Cloudflare
| Operação | Endpoint | Permissão |
|----------|----------|-----------|
| Criar conta | `POST /accounts` | **Tenant admin** (organização Ligbox) |
| Criar zona | `POST /zones` | Account Zone Edit na conta nova |
| Convidar gestor | `POST /accounts/{id}/members` | Account User Management |
| Abuse contact | `settings.abuse_contact_email` | Pode ser `admin@cliente.com.br` desde Fase A |
> `POST /accounts` está limitado a **tenant admins**. Se Ligbox ainda não tiver Tenant, alternativa interina: zonas na conta mãe (Fase 1) até activar Tenant.
### 3. Registo interno (VM112)
Ficheiro ou DB por domínio (`/var/lib/ligbox-wizard/cf_client_accounts/{domain}.json`):
```json
{
"domain": "empresa.com.br",
"project_id": "005",
"ligbox_email": "proj_005@ligbox.com.br",
"cloudflare_account_id": "…",
"cloudflare_account_name": "Empresa XYZ — empresa.com.br",
"zone_id": "…",
"client_manager_email": null,
"handoff_status": "pending",
"created_at": "2026-06-22T12:00:00Z",
"handoff_at": null
}
```
### 4. Gatilho handoff
Automático quando **todos** verdadeiros:
- `GET /dns/verify/{domain}``ready: true`
- `POST /account/create` → sucesso
- `infrastructure.ready` (se aplicável)
Então: `POST /dns/cloudflare/handoff-manager` com `{ domain, manager_email: "admin@empresa.com.br" }`.
---
## Endpoints planeados (Fase 2)
| Método | Path | Descrição |
|--------|------|-----------|
| POST | `/dns/cloudflare/provision-client-account` | Cria conta CF + zona + registo `proj_*` |
| POST | `/dns/cloudflare/handoff-manager` | Convida gestor do domínio cliente |
| GET | `/dns/cloudflare/client-account/{domain}` | Estado handoff (ops) |
---
## Critérios de aceitação (Fase 2)
1. Cliente novo → conta CF com nome do domínio e membro `proj_{id}@ligbox.com.br`.
2. Zona + apontamentos mail na **conta do cliente**, não na mãe ibytera.
3. Após go-live → convite `admin@cliente.com.br` enviado e registado.
4. `proj_*` permanece acessível à Ligbox para suporte.
5. BYO / registrador **não** passam por este fluxo.
---
## Pergunta em aberto (Roger)
**Formato do `project_id`:** sequencial `005` (`proj_005@`) ou slug do domínio (`proj_empresa-com-br@`)? Recomendação: **sequencial numérico** — mais limpo e alinhado ao exemplo.

View file

@ -0,0 +1,110 @@
# DNS Viewer V1 — Execução e versionamento
**Responsável:** Cursor Agent · **Aprovado por:** Roger
**Data:** 2026-06-25
**Spec:** [dns-viewer.md](../dns-viewer.md) · Rollback: [DNS-VIEWER-ROLLBACK.md](./DNS-VIEWER-ROLLBACK.md)
---
## Escopo desta execução
| Fase | Incluída agora | Entregável |
|------|----------------|------------|
| V0 | ✅ baseline | `cloudflare_dns.py` + modal Desk (já existe) |
| V1 | 📋 próximo deploy | API `GET /api/v1/dns/viewer/{domain}` |
| V1b | 📋 | `dns-viewer.js` + migrar modal Desk |
| V2V4 | ⏳ futuro | OpenPanel, Console, Wizard |
---
## Checklist pré-deploy (obrigatório)
```bash
# CT130 — tag git (usar SSHPASS se preflight/rollback a partir do Spec Hub)
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform
git tag -a dns-viewer-pre-v1-$(date +%Y%m%d) -m "Antes DNS Viewer V1"
# VM122 — backup (CT130: export SSHPASS=805353)
export SSHPASS='805353' # opcional — só se SSH key falhar
bash specs/037-dns-multi-cloudflare-orchestration/deploy/scripts/preflight-dns-viewer.sh \
--host root@10.10.10.122
```
---
## Ficheiros versionados (monorepo)
| Ficheiro | Fase | Notas |
|----------|------|-------|
| `projects/ops-desk/api/app/cloudflare_dns.py` | V0 | Não remover — provider CF |
| `projects/ops-desk/api/app/dns_viewer.py` | V1 | **Novo** orquestrador |
| `projects/ops-desk/api/app/openpanel_dns.py` | V2 | Novo — opcional |
| `projects/ops-desk/api/app/main.py` | V1 | Rota viewer + flag |
| `projects/ops-desk/api/app/permissions.py` | V1 | `can_read_dns_viewer` |
| `projects/ops-desk/frontend/assets/dns-viewer.js` | V1b | **Novo** componente |
| `projects/ops-desk/frontend/assets/app.js` | V1b | Integrar viewer |
| `projects/ops-desk/frontend/index.html` | V1b | Cache bust `?v=` |
| `projects/wizard/backend/app/services/dns_viewer.py` | V4 | Wizard variant |
---
## Deploy VM122 (após implementação V1/V1b)
```bash
# CT130 ou laptop com rsync
REPO=/opt/ligbox-spec-hub/repos/ligbox-ops-platform
rsync -av --delete \
"$REPO/projects/ops-desk/api/app/" \
root@10.10.10.122:/opt/ligbox-ops-platform/api/app/
rsync -av \
"$REPO/projects/ops-desk/frontend/assets/" \
root@10.10.10.122:/opt/ligbox-ops-platform/frontend/assets/
ssh root@10.10.10.122 'cd /opt/ligbox-ops-platform && \
grep -q DNS_VIEWER_ENABLED .env || echo DNS_VIEWER_ENABLED=1 >> .env && \
docker compose -f docker-compose.mvp.yml build api frontend && \
docker compose -f docker-compose.mvp.yml up -d api frontend'
```
---
## Validação pós-deploy
```bash
bash specs/037-dns-multi-cloudflare-orchestration/deploy/scripts/verify-dns-viewer.sh \
--host 10.10.10.122
# Manual UI
# Desk → Overview → tenant VM112 → domínio → secção DNS unificada
# Browser devtools: zero SyntaxError em app.js / dns-viewer.js
```
---
## Rollback
Ver **[DNS-VIEWER-ROLLBACK.md](./DNS-VIEWER-ROLLBACK.md)** — ordem recomendada:
1. `DNS_VIEWER_ENABLED=0` (30 segundos)
2. Rollback frontend se UI partida
3. Rollback API se 500
4. `git revert` CT130 se necessário
---
## Registo de versões (preencher após cada deploy)
| Data | Tag git | VM122 backup dir | Commit | Notas |
|------|---------|------------------|--------|-------|
| 2026-06-25 | `dns-viewer-pre-v1-20260625` | `.backups/dns-viewer-20260625-161839` | `7e4920a` | Pré-deploy |
| 2026-06-25 | — | tag Docker `post-dns-viewer-v1-20260625` | _(working tree)_ | **V1+V1b deploy OK** · verify PASSED |
---
## Pendente V2+
- OpenPanel BIND provider
- Console `console.ligbox.com.br/admin/dominio`
- Wizard `GET /api/onboarding/dns/viewer/{domain}`
- Formalizar `dns.viewer.read` em Spec 027

View file

@ -0,0 +1,225 @@
# DNS Viewer — Rollback (Spec 037-DNS-VIEWER)
**Data:** 2026-06-25
**Spec:** 037 · **Exec:** DNS-VIEWER-V1-20260625
**Ambiente:** VM122 Desk · VM112 Wizard · VM123 Console (fases V3+) · CT130 Spec Hub
---
## O que muda por fase
| Fase | VM | Alteração | Rollback isolado? |
|------|-----|-----------|-------------------|
| **V0** (baseline) | VM122 | `cloudflare_dns.py` + modal CF em `app.js` | — baseline actual |
| **V1** | VM122 | `dns_viewer.py` + `GET /api/v1/dns/viewer/{domain}` | ✅ feature flag |
| **V1b** | VM122 | `dns-viewer.js` + UI unificada Desk | ✅ frontend only |
| **V2** | VM122 | `openpanel_dns.py` + provider BIND | ✅ desactivar provider |
| **V3** | VM123 | `/admin/dominio` consome viewer API | ✅ frontend Console |
| **V4** | VM112 | `GET /api/onboarding/dns/viewer/{domain}` | ✅ wizard only |
**Nunca alterado pelo DNS Viewer:** CT114 Traefik, pfSense, `/etc/network/interfaces` Proxmox, zonas Cloudflare reais, OpenPanel BIND dados.
---
## Feature flag (rollback sem redeploy completo)
Variável no `.env` VM122 (`/opt/ligbox-ops-platform/.env`):
```bash
# 1 = endpoint + UI novo; 0 = só legado CF
DNS_VIEWER_ENABLED=1
```
Com `DNS_VIEWER_ENABLED=0`:
- `GET /api/v1/dns/viewer/{domain}`**404** ou redirect interno para legado
- Desk UI usa **`fetchCloudflareDns`** + `htmlCloudflareDnsCard` (V0)
- Endpoint legado **`GET /api/v1/dns/cloudflare/records`** mantém-se **sempre** activo até cutover final
Reinício rápido:
```bash
ssh root@10.10.10.122
cd /opt/ligbox-ops-platform
sed -i 's/^DNS_VIEWER_ENABLED=.*/DNS_VIEWER_ENABLED=0/' .env
docker compose -f docker-compose.mvp.yml up -d api frontend
```
---
## Pré-requisitos rollback — verificar backups
Backups criados **antes** de cada deploy (script `preflight-dns-viewer.sh`):
```bash
ssh root@10.10.10.122
ls -la /opt/ligbox-ops-platform/.backups/dns-viewer-*
docker images | grep -E 'ops-platform|ligbox-ops'
```
Git CT130:
```bash
ssh root@10.10.10.130
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform
git log --oneline -5
git tag -l 'dns-viewer-*'
```
---
## Rollback rápido — só frontend Desk (V1b)
Quando a API está OK mas a UI partiu (SyntaxError, modal vazio, «Carregando…» infinito):
```bash
ssh root@10.10.10.122
cd /opt/ligbox-ops-platform
BACKUP=$(ls -td .backups/dns-viewer-*/frontend/assets 2>/dev/null | head -1)
if [ -n "$BACKUP" ]; then
rm -rf frontend/assets/app.js frontend/assets/dns-viewer.js 2>/dev/null
cp -a "$BACKUP"/. frontend/assets/
fi
docker compose -f docker-compose.mvp.yml build --no-cache frontend
docker compose -f docker-compose.mvp.yml up -d frontend
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8091/
```
**Cache browser:** incrementar `app.js?v=` em `frontend/index.html` no backup ou manualmente.
---
## Rollback rápido — API Desk (V1)
Quando `/dns/viewer` devolve 500 ou bloqueia Overview:
```bash
ssh root@10.10.10.122
cd /opt/ligbox-ops-platform
BACKUP=$(ls -td .backups/dns-viewer-*/api/app 2>/dev/null | head -1)
if [ -n "$BACKUP" ]; then
cp -a "$BACKUP"/cloudflare_dns.py api/app/ 2>/dev/null || true
rm -f api/app/dns_viewer.py api/app/openpanel_dns.py
# Restaurar main.py do tarball se existir
if [ -f "$BACKUP/../main.py.bak" ]; then
cp -a "$BACKUP/../main.py.bak" api/app/main.py
fi
fi
grep -q DNS_VIEWER_ENABLED=0 .env || echo 'DNS_VIEWER_ENABLED=0' >> .env
docker compose -f docker-compose.mvp.yml build --no-cache api
docker compose -f docker-compose.mvp.yml up -d api
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health
```
Validar legado:
```bash
# Com token JWT staff — ou curl interno documentado em verify-dns-viewer.sh
curl -sS 'http://127.0.0.1:8080/api/v1/dns/cloudflare/records?domain=ligbox.com.br' \
-H "Authorization: Bearer $TOKEN" | head -c 200
```
---
## Rollback imagem Docker (VM122)
```bash
ssh root@10.10.10.122
cd /opt/ligbox-ops-platform
docker tag ligbox-ops-platform-api:pre-dns-viewer-YYYYMMDD ligbox-ops-platform-api:latest
docker compose -f docker-compose.mvp.yml up -d api
```
Substituir `YYYYMMDD` pela data do backup em `docker images`.
---
## Rollback VM112 Wizard (V4 apenas)
```bash
ssh root@10.10.10.112
cd /opt/ligbox-wizard
BACKUP=$(ls -td .backups/dns-viewer-* 2>/dev/null | head -1)
if [ -n "$BACKUP" ]; then
cp -a "$BACKUP"/backend/. backend/
fi
systemctl restart ligbox-wizard 2>/dev/null || docker compose restart backend
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/health
```
Passo DNS wizard volta ao comportamento pré-viewer; **provision-zone / apply não são revertidos** (só UI/API leitura).
---
## Rollback VM123 Console `/admin/dominio` (V3 apenas)
```bash
ssh root@10.10.10.123
cd /opt/ligbox-ops-console
BACKUP=$(ls -td frontend.bak-dns-viewer-* 2>/dev/null | head -1)
if [ -n "$BACKUP" ]; then
rm -rf frontend
cp -a "$BACKUP" frontend
fi
docker compose build --no-cache && docker compose up -d
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8100/health
```
Rota `/admin/dominio` pode mostrar placeholder ou 404 — **não afecta** Desk nem wizard apply.
---
## Rollback código (CT130 / monorepo)
```bash
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform
git log --oneline --grep='dns-viewer' -5
git revert <commit-dns-viewer> --no-edit
# ou checkout tag pré-deploy:
git checkout dns-viewer-pre-v1-YYYYMMDD -- projects/ops-desk/
./portal/refresh-spec-driver.sh 2>/dev/null || true
rsync -a specs/037-dns-multi-cloudflare-orchestration/ \
/opt/ligbox-spec-hub/obsidian-vault/ligbox-ops-platform/specs/037-dns-multi-cloudflare-orchestration/ 2>/dev/null || true
```
Re-deploy VM122 após revert git (rsync ou pull no host).
---
## Sintomas → acção
| Sintoma | Causa provável | Acção |
|---------|----------------|-------|
| Desk «Carregando…» em todo o site | SyntaxError em `app.js` | Rollback frontend V1b |
| Modal domínio sem DNS | API 500 viewer | `DNS_VIEWER_ENABLED=0` + rollback API |
| Overview OK, viewer vazio | CF token / zona | **Não rollback** — corrigir env; legado igual |
| 403 no viewer | RBAC | Ajustar role, não rollback |
| Wizard passo DNS partido | V4 VM112 | Rollback wizard only |
| Console `/admin/dominio` branco | V3 VM123 | Rollback Console frontend |
---
## Validação pós-rollback
```bash
# VM122 — script completo
bash specs/037-dns-multi-cloudflare-orchestration/deploy/scripts/verify-dns-viewer.sh \
--host 10.10.10.122 --legacy-only
```
Esperado:
- `GET /health` → 200
- `GET /api/v1/dns/cloudflare/records?domain=...` → 200 ou 403 (auth)
- Desk Overview abre modal domínio sem erro consola browser
- **Nenhuma** zona Cloudflare alterada (viewer é read-only)
---
## Referências
- Exec deploy: [DNS-VIEWER-EXEC-20260625.md](./DNS-VIEWER-EXEC-20260625.md)
- Preflight backup: [scripts/preflight-dns-viewer.sh](./scripts/preflight-dns-viewer.sh)
- Spec: [dns-viewer.md](../dns-viewer.md)
- Console v2 rollback (padrão): `specs/019-.../deploy/CONSOLE-V2-ROLLBACK.md`

View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Spec 037-DNS-VIEWER — backup pré-deploy (VM122 + opcional VM112/VM123)
set -euo pipefail
HOST="root@10.10.10.122"
STAMP=$(date +%Y%m%d-%H%M%S)
TAG="dns-viewer-pre-${STAMP}"
ssh_cmd() {
if [[ -n "${SSHPASS:-}" ]] && command -v sshpass >/dev/null; then
sshpass -e ssh -o StrictHostKeyChecking=no "$@"
else
ssh -o StrictHostKeyChecking=no "$@"
fi
}
usage() {
echo "Usage: $0 [--host root@10.10.10.122] [--wizard] [--console]"
exit 1
}
DO_WIZARD=0
DO_CONSOLE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--wizard) DO_WIZARD=1; shift ;;
--console) DO_CONSOLE=1; shift ;;
-h|--help) usage ;;
*) echo "Unknown: $1"; usage ;;
esac
done
echo "=== DNS Viewer preflight — $TAG ==="
echo "Host Desk: $HOST"
ssh_cmd "$HOST" bash -s <<EOF
set -euo pipefail
BASE=/opt/ligbox-ops-platform
BK="\$BASE/.backups/dns-viewer-${STAMP}"
mkdir -p "\$BK/api" "\$BK/frontend/assets"
echo "[VM122] Backup api/app..."
cp -a "\$BASE/api/app/cloudflare_dns.py" "\$BK/api/" 2>/dev/null || true
cp -a "\$BASE/api/app/main.py" "\$BK/api/main.py.bak" 2>/dev/null || true
cp -a "\$BASE/api/app/permissions.py" "\$BK/api/" 2>/dev/null || true
[ -f "\$BASE/api/app/dns_viewer.py" ] && cp -a "\$BASE/api/app/dns_viewer.py" "\$BK/api/" || true
[ -f "\$BASE/api/app/openpanel_dns.py" ] && cp -a "\$BASE/api/app/openpanel_dns.py" "\$BK/api/" || true
echo "[VM122] Backup frontend/assets..."
cp -a "\$BASE/frontend/assets/app.js" "\$BK/frontend/assets/" 2>/dev/null || true
cp -a "\$BASE/frontend/assets/dns-viewer.js" "\$BK/frontend/assets/" 2>/dev/null || true
cp -a "\$BASE/frontend/index.html" "\$BK/frontend/" 2>/dev/null || true
echo "[VM122] Snapshot .env (sem secrets no stdout)..."
grep -E '^(DNS_VIEWER_ENABLED|CLOUDFLARE)' "\$BASE/.env" 2>/dev/null | sed 's/=.*/=***/' || true
cp -a "\$BASE/.env" "\$BK/env.bak" 2>/dev/null || true
echo "[VM122] Docker image tag..."
cd "\$BASE"
if command -v docker-compose >/dev/null; then
IMG=\$(docker-compose -f docker-compose.mvp.yml images -q api 2>/dev/null | head -1 || true)
else
IMG=\$(docker compose -f docker-compose.mvp.yml images -q api 2>/dev/null | head -1 || true)
fi
if [ -n "\$IMG" ]; then
docker tag "\$IMG" "ligbox-ops-platform-api:pre-dns-viewer-${STAMP}" 2>/dev/null || \
docker tag ligbox-ops-platform_api:latest "ligbox-ops-platform-api:pre-dns-viewer-${STAMP}" 2>/dev/null || true
fi
echo "[VM122] Backup path: \$BK"
ls -la "\$BK"
EOF
if [[ "$DO_WIZARD" -eq 1 ]]; then
echo "=== VM112 wizard backup ==="
ssh_cmd -o StrictHostKeyChecking=no root@10.10.10.112 bash -s <<EOF
set -euo pipefail
BK=/opt/ligbox-wizard/.backups/dns-viewer-${STAMP}
mkdir -p "\$BK"
cp -a /opt/ligbox-wizard/backend/app/services/dns_viewer.py "\$BK/" 2>/dev/null || true
cp -a /opt/ligbox-wizard/backend/app/routers/dns.py "\$BK/" 2>/dev/null || true
echo "Wizard backup: \$BK"
EOF
fi
if [[ "$DO_CONSOLE" -eq 1 ]]; then
echo "=== VM123 console backup ==="
ssh_cmd -o StrictHostKeyChecking=no root@10.10.10.123 bash -s <<EOF
set -euo pipefail
cd /opt/ligbox-ops-console
cp -a frontend "frontend.bak-dns-viewer-${STAMP}"
echo "Console backup: frontend.bak-dns-viewer-${STAMP}"
EOF
fi
echo ""
echo "=== Preflight OK — tag sugerida git: ${TAG} ==="
echo "Rollback: specs/037-dns-multi-cloudflare-orchestration/deploy/DNS-VIEWER-ROLLBACK.md"

View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Spec 037-DNS-VIEWER — rollback automatizado VM122 (feature flag + restore último backup)
set -euo pipefail
HOST="root@10.10.10.122"
SCOPE="all" # all | api | frontend
ssh_cmd() {
if [[ -n "${SSHPASS:-}" ]] && command -v sshpass >/dev/null; then
sshpass -e ssh -o StrictHostKeyChecking=no "$@"
else
ssh -o StrictHostKeyChecking=no "$@"
fi
}
usage() {
echo "Usage: $0 [--host root@10.10.10.122] [--scope all|api|frontend] [--dry-run]"
exit 1
}
DRY=0
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--scope) SCOPE="$2"; shift 2 ;;
--dry-run) DRY=1; shift ;;
-h|--help) usage ;;
*) echo "Unknown: $1"; usage ;;
esac
done
run() {
if [[ "$DRY" -eq 1 ]]; then
echo "[dry-run] $*"
else
eval "$@"
fi
}
echo "=== DNS Viewer rollback — scope=$SCOPE host=$HOST ==="
ssh_cmd "$HOST" bash -s <<EOF
set -euo pipefail
BASE=/opt/ligbox-ops-platform
BK=\$(ls -td "\$BASE/.backups/dns-viewer-"* 2>/dev/null | head -1)
if [ -z "\$BK" ]; then
echo "ERRO: nenhum backup em \$BASE/.backups/dns-viewer-*"
echo "Correr preflight-dns-viewer.sh antes do próximo deploy."
exit 1
fi
echo "Usando backup: \$BK"
# Feature flag off
if grep -q '^DNS_VIEWER_ENABLED=' "\$BASE/.env" 2>/dev/null; then
sed -i 's/^DNS_VIEWER_ENABLED=.*/DNS_VIEWER_ENABLED=0/' "\$BASE/.env"
else
echo 'DNS_VIEWER_ENABLED=0' >> "\$BASE/.env"
fi
echo "DNS_VIEWER_ENABLED=0"
SCOPE="${SCOPE}"
DRY=${DRY}
restore_api() {
[ -f "\$BK/api/cloudflare_dns.py" ] && cp -a "\$BK/api/cloudflare_dns.py" "\$BASE/api/app/"
[ -f "\$BK/api/main.py.bak" ] && cp -a "\$BK/api/main.py.bak" "\$BASE/api/app/main.py"
[ -f "\$BK/api/permissions.py" ] && cp -a "\$BK/api/permissions.py" "\$BASE/api/app/"
rm -f "\$BASE/api/app/dns_viewer.py" "\$BASE/api/app/openpanel_dns.py"
echo "API ficheiros restaurados"
}
restore_frontend() {
[ -f "\$BK/frontend/assets/app.js" ] && cp -a "\$BK/frontend/assets/app.js" "\$BASE/frontend/assets/"
rm -f "\$BASE/frontend/assets/dns-viewer.js"
[ -f "\$BK/frontend/index.html" ] && cp -a "\$BK/frontend/index.html" "\$BASE/frontend/"
echo "Frontend restaurado"
}
if [ "\$DRY" -eq 1 ]; then
echo "[dry-run] restore scope=\$SCOPE"
exit 0
fi
case "\$SCOPE" in
api) restore_api ;;
frontend) restore_frontend ;;
all) restore_api; restore_frontend ;;
*) echo "scope inválido"; exit 1 ;;
esac
cd "\$BASE"
docker compose -f docker-compose.mvp.yml build api frontend
docker compose -f docker-compose.mvp.yml up -d api frontend
sleep 3
curl -sS -o /dev/null -w 'health=%{http_code}\n' http://127.0.0.1:8080/health
curl -sS -o /dev/null -w 'frontend=%{http_code}\n' http://127.0.0.1:8091/
EOF
echo ""
echo "=== Rollback concluído — correr verify-dns-viewer.sh --legacy-only ==="

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Spec 037-DNS-VIEWER — smoke tests pós-deploy ou pós-rollback
set -euo pipefail
HOST="10.10.10.122"
LEGACY_ONLY=0
DOMAIN="${DNS_TEST_DOMAIN:-ligbox.com.br}"
usage() {
echo "Usage: $0 [--host IP] [--legacy-only] [--domain NAME]"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--legacy-only) LEGACY_ONLY=1; shift ;;
--domain) DOMAIN="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown: $1"; usage ;;
esac
done
API="http://${HOST}:8080"
FE="http://${HOST}:8091"
FAIL=0
check() {
local name="$1"
local code="$2"
local expect="$3"
if [[ "$code" == "$expect" ]]; then
echo " OK $name → HTTP $code"
else
echo " FAIL $name → HTTP $code (expected $expect)"
FAIL=1
fi
}
echo "=== DNS Viewer verify — $HOST domain=$DOMAIN ==="
CODE=$(curl -sS -o /dev/null -w '%{http_code}' "${API}/health" 2>/dev/null || echo "000")
check "API /health" "$CODE" "200"
CODE=$(curl -sS -o /dev/null -w '%{http_code}' "${FE}/" 2>/dev/null || echo "000")
check "Frontend /" "$CODE" "200"
# Sem auth — endpoints devem 401/403, não 500
CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
"${API}/api/v1/dns/cloudflare/records?domain=${DOMAIN}" 2>/dev/null || echo "000")
if [[ "$CODE" == "401" || "$CODE" == "403" ]]; then
echo " OK legacy CF records (no auth) → HTTP $CODE"
elif [[ "$CODE" == "200" ]]; then
echo " OK legacy CF records → HTTP 200 (auth bypass/local?)"
else
echo " FAIL legacy CF records → HTTP $CODE (expected 401/403/200)"
FAIL=1
fi
if [[ "$LEGACY_ONLY" -eq 0 ]]; then
CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
"${API}/api/v1/dns/viewer/${DOMAIN}" 2>/dev/null || echo "000")
if [[ "$CODE" == "401" || "$CODE" == "403" ]]; then
echo " OK dns/viewer (no auth) → HTTP $CODE"
elif [[ "$CODE" == "200" ]]; then
echo " OK dns/viewer → HTTP 200"
elif [[ "$CODE" == "404" ]]; then
echo " WARN dns/viewer → 404 (DNS_VIEWER_ENABLED=0 ou não deployado)"
else
echo " FAIL dns/viewer → HTTP $CODE"
FAIL=1
fi
fi
# JS assets existem
for f in app.js dns-viewer.js; do
CODE=$(curl -sS -o /dev/null -w '%{http_code}' "${FE}/assets/${f}" 2>/dev/null || echo "000")
if [[ "$f" == "dns-viewer.js" && "$CODE" == "404" ]]; then
echo " WARN assets/${f} → 404 (V1b não deployado — OK se legacy-only)"
else
check "assets/${f}" "$CODE" "200"
fi
done
echo ""
if [[ "$FAIL" -eq 0 ]]; then
echo "=== Verify PASSED ==="
exit 0
else
echo "=== Verify FAILED — ver DNS-VIEWER-ROLLBACK.md ==="
exit 1
fi

View file

@ -0,0 +1,369 @@
# Spec 037-DNS-VIEWER — Painel DNS read-only unificado
**Criado:** 2026-06-25
**Solicitado por:** Roger
**Status:** 📋 Especificado — implementação Fase 1 parcial (Desk CF only)
**Prioridade:** P1
**Depende de:** [spec.md](./spec.md) · [004 cloudflare-zone-provision](../004-onboard-funnel-events/cloudflare-zone-provision.md) · [009](../009-ops-audit-overview/spec.md) · [028](../028-openpanel-ce-ligbox-reengineering/DNS53_OPENPANEL_PORTA53.md) · [035 §5.5](../035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md) · [027 RBAC](../027-desk-rbac-function-matrix/spec.md)
---
## 1. Objectivo
Permitir que **staff Ligbox** e **gerente de domínio** vejam **todos os apontamentos DNS** relevantes para um domínio — **sem alterar** registos na UI — com:
1. **Lista completa read-only** (tipo, nome, conteúdo, TTL, função)
2. **Origem do DNS** (Cloudflare Ligbox · BYO · OpenPanel BIND · registrador externo · resolução pública)
3. **Link «Editar aqui»** para a consola correcta (Cloudflare, OpenPanel, registrador)
4. **Regra wizard:** se o cliente escolhe **trazer DNS para Ligbox** → mostrar o que **será / foi aplicado**; caso contrário → mostrar **onde o DNS está no momento** da operação
**Fora de scope:** PATCH/POST/DELETE de registos no Desk ou Console — edição só via deep-link externo.
---
## 2. Problema actual (Roger 2026-06-25)
| Superfície | O que existe hoje | Lacuna |
|------------|-------------------|--------|
| Desk Overview → modal domínio | Tabela CF via `GET /api/v1/dns/cloudflare/records` | Só Cloudflare; sem OpenPanel; sem link editar |
| Desk Serviços IaaS | Resumo zona CF | Sem tabela de registos |
| Wizard passo DNS | Modos Ligbox/BYO/Registrador (037) | Cliente vê passos; staff não tem painel unificado |
| OpenPanel BIND | Zonas em `openpanel_dns` VM123 | **Zero UI Desk/Console** — operador não sabe apontamentos |
| Console `/admin` §5.5 | MX/NS status (mock) | Sem lista completa |
---
## 3. Regra de produto — caminho Ligbox vs externo
Esta regra **obrigatória** aplica-se em **Wizard**, **Desk** e **Console `/admin`**.
```mermaid
flowchart TD
A[Domínio + sessão onboarding] --> B{dns_mode escolhido?}
B -->|ligbox_cf_*| C[Modo PLANEADO / APLICADO]
B -->|byo_cf| D[Modo BYO — zona cliente CF]
B -->|external / registrador| E[Modo ACTUAL — DNS público]
B -->|openpanel_bind| F[Modo OPENPANEL — zona BIND VM123]
C --> C1[Mostrar NS Cloudflare Ligbox]
C --> C2[Mostrar mail_dns_records preview]
C --> C3[Após apply: registos CF reais]
C --> C4[Link: Cloudflare conta Ligbox]
D --> D1[Mostrar registos zona BYO via token vault]
D --> D2[Link: dash.cloudflare.com zona cliente]
E --> E1[GET dns/instructions — o que falta colar]
E --> E2[GET dns/verify + dig público 009]
E --> E3[NS actuais no registrador]
E --> E4[Link: registrador ou painel CF cliente]
F --> F1[Listar zona BIND OpenPanel]
F --> F2[Link: OpenPanel DNS UI]
```
### 3.1 Tabela de decisão UI
| `dns_mode` (persistido) | Badge UI | Fonte de dados | O que mostrar |
|-------------------------|----------|----------------|---------------|
| `ligbox_cf_ligit` / `itecnologys` / `ibytera` | **DNS Ligbox** | CF API conta Ligbox + `mail_dns_records()` | Registos **aplicados**; se pré-NS → **preview** + NS a configurar |
| `ligbox_cf_provision_pending` | **DNS Ligbox (aguarda NS)** | `provision-zone` + preview | NS Cloudflare + tabela «será aplicado após NS» |
| `byo_cf` | **Cloudflare cliente** | Token BYO vault + CF API | Registos actuais na zona BYO |
| `external` / `registrar` | **DNS externo** | `dns/instructions` + `dig` público | Estado **actual** + instruções manuais |
| `openpanel_bind` | **OpenPanel BIND** | OpenAdmin API / zone file VM123 | Registos autoritativos `:53` Ligbox |
| `unknown` | **A determinar** | `dns/resolve` + NS lookup | NS actuais + sugestão de caminho |
### 3.2 Texto UX (gerente / staff)
| Modo | Mensagem principal |
|------|-------------------|
| Ligbox (pré-apply) | «Estes apontamentos **serão configurados** na Cloudflare Ligbox quando confirmar o passo DNS.» |
| Ligbox (pós-apply) | «Apontamentos **activos** na Cloudflare Ligbox.» |
| Externo | «O domínio usa DNS **fora da Ligbox**. Abaixo: o que está **publicamente** resolvido agora.» |
| OpenPanel | «Zona servida pelo **DNS Ligbox (OpenPanel)** em `95.216.14.162`.» |
---
## 4. Fontes de dados (backend)
### 4.1 Matriz de providers
| Provider ID | Quando usar | API / método | Edit link template |
|-------------|-------------|--------------|-------------------|
| `cf_ligbox` | Zona numa conta Ligbox (037) | CF API token conta + `zone_id` | `https://dash.cloudflare.com/{account_id}/{zone_name}/dns` |
| `cf_byo` | BYO wizard | Token vault sessão / entitlements | `https://dash.cloudflare.com/` (zona cliente) |
| `openpanel_bind` | Subdomínio/site bundle OP | OpenAdmin `GET /api/domains/{domain}/dns` ou `rndc`/zone export | `https://openpanel.ligbox.com.br/domains/{domain}/dns` |
| `public_resolver` | Sempre (fallback / externo) | `dig` + `dns/verify` VM112 | — |
| `planned_ligbox` | Ligbox antes de apply | `mail_dns_records(domain)` wizard | — (preview only) |
### 4.2 Endpoint unificado (novo — Desk + Console)
```http
GET /api/v1/dns/viewer/{domain}
Authorization: Bearer <JWT staff ou domain-admin>
Query: ?include_public=true&include_planned=true
```
**Response:**
```json
{
"domain": "empresa.com.br",
"dns_mode": "ligbox_cf_ibytera",
"mode_label": "Cloudflare Ligbox (ibytera)",
"display_mode": "applied",
"authoritative_source": "cf_ligbox",
"nameservers": {
"current_public": ["ns1.registro.br", "ns2.registro.br"],
"ligbox_cloudflare": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"],
"match_ligbox": false
},
"records": [
{
"source": "cf_ligbox",
"status": "applied",
"type": "MX",
"name": "empresa.com.br",
"content": "mail.empresa.com.br",
"priority": 10,
"ttl": 3600,
"purpose": "mx",
"email_related": true
}
],
"planned_records": [],
"public_checks": {
"mx": { "ok": true, "values": ["10 mail.empresa.com.br"] },
"spf": { "ok": true },
"dkim": { "ok": false, "hint": "TXT _domainkey ausente" },
"dmarc": { "ok": true }
},
"edit_links": [
{
"label": "Editar na Cloudflare (Ligbox)",
"provider": "cf_ligbox",
"url": "https://dash.cloudflare.com/…/empresa.com.br/dns",
"roles": ["super_admin", "ops_lead", "devops", "seo"]
}
],
"instructions": null,
"errors": []
}
```
**Variante wizard (sessão cliente — sem staff JWT):**
```http
GET /api/onboarding/dns/viewer/{domain}
Header: X-Onboarding-Session: {session}
```
Reutiliza mesma shape; filtra `edit_links` vazios para cliente; mostra `planned_records` quando `display_mode=planned`.
### 4.3 Endpoints existentes reutilizados
| Endpoint | Papel no viewer |
|----------|-----------------|
| `GET /api/onboarding/dns/resolve/{domain}` | Detectar `dns_mode` + conta CF |
| `GET /api/onboarding/dns/instructions/{domain}` | Modo externo — o que colar |
| `GET /api/onboarding/dns/verify/{domain}` | Checks públicos |
| `GET /api/onboarding/dns/portal-onboarding/{domain}` | NS + passos registrador |
| `GET /api/v1/dns/cloudflare/records` | **Legado Desk** — migrar para viewer |
| Spec 009 audit | `public_checks` MX/SPF/DKIM/DMARC |
### 4.4 OpenPanel BIND (Fase 2 viewer)
| Método | Descrição |
|--------|-----------|
| Desk proxy | `GET /api/v1/dns/openpanel/records?domain=` |
| Backend | Bridge VM123 → OpenAdmin list records |
| Fallback | SSH `docker exec openpanel_dns` zone dump (read-only) |
Ver [028 DNS53](../028-openpanel-ce-ligbox-reengineering/DNS53_OPENPANEL_PORTA53.md).
---
## 5. Superfícies UI
### 5.1 Desk VM122 (staff)
| Local | Componente | RBAC |
|-------|------------|------|
| Overview → modal tenant → clicar domínio | Secção **«DNS do domínio»** (substitui só-CF) | `cloudflare_dns.read` + roles 027 |
| Serviços IaaS → modal domínio | Mesma secção embed | idem |
| Chamado / ticket domínio | Tab DNS read-only | technician+ |
**Wireframe Desk:**
```
┌─ DNS — empresa.com.br ────────────────────────────────────────┐
│ [DNS Ligbox ▼] Zona activa · 14 registos · 6 e-mail │
│ NS públicos: ns1.registro.br … ⚠ ainda não apontam CF Ligbox │
├───────────────────────────────────────────────────────────────┤
│ Função │ Nome │ Tipo │ Conteúdo │ Origem │ Estado │
│ MX │ @ │ MX │ mail… │ CF Lig │ ✅ aplicado │
│ SPF │ @ │ TXT │ v=spf1… │ CF Lig │ ✅ aplicado │
├───────────────────────────────────────────────────────────────┤
│ Checks públicos (dig): MX ✅ SPF ✅ DKIM ⚠ DMARC ✅ │
├───────────────────────────────────────────────────────────────┤
│ [Editar na Cloudflare ↗] [Verificação avançada] [Actualizar]│
└───────────────────────────────────────────────────────────────┘
```
### 5.2 Console gerente `/admin` (Spec 035)
Secção **Domínio & DNS** — ver [domain-manager-console-ui.md §5.5](../035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md#55-domínio--dns-dns-viewer).
Gerente vê read-only + link externo **se** tiver permissão (BYO: link CF cliente; Ligbox: mensagem «contacte suporte» ou link help).
### 5.3 Wizard passo DNS (cliente)
| Estado wizard | Painel lateral viewer |
|---------------|----------------------|
| Escolheu Ligbox | Preview `planned_records` + NS |
| Escolheu BYO | Registos actuais BYO + diff vs mail |
| Escolheu Registrador | `instructions` + verify |
---
## 6. Links «Editar aqui» (deep-link)
| Provider | Quem vê o botão | URL | Mecanismo |
|----------|-----------------|-----|-----------|
| Cloudflare Ligbox | staff `super_admin`, `ops_lead`, `devops`, `seo` | dash.cloudflare.com | Conta + zone_id de `dns/resolve` |
| Cloudflare BYO | gerente (zona dele) + staff | dash.cloudflare.com | Zona BYO |
| OpenPanel | staff + gerente hub | openpanel.ligbox.com.br | Autologin bridge 027 |
| Registrador externo | gerente | URL detectada ou genérica | `instructions.registrar_url` |
| Registro.br | gerente BR | https://registro.br | Manual |
**Desk:** botão abre **nova tab** — nunca iframe CF (CSP).
**Implementação Fase 1:** só link CF Ligbox (conta conhecida). Fase 2: OpenPanel autologin.
---
## 7. RBAC (Spec 027)
| Acção | Roles |
|-------|-------|
| `dns.viewer.read` — ver painel | super_admin, ops_lead, technician, noc, seo, devops, developer |
| Ver link Cloudflare Ligbox | super_admin, ops_lead, devops, seo |
| Ver link OpenPanel | super_admin, ops_lead, sales_admin, sales_support, seo |
| Gerente domínio `/admin` | Só domínio próprio; sem CF Ligbox interna |
| Agente A2/A3 (Desk) | Lê viewer API — sugere fixes, não edita |
Formalizar permissão `dns.viewer.read` em `data-model.md` 027 (backlog).
---
## 8. Persistência `dns_mode`
Gravar em:
| Store | Campo |
|-------|-------|
| Wizard sessão onboarding | `session.dns_mode`, `session.cf_account_id` |
| `bundle_entitlements` (035) | `dns_mode`, `dns_provider`, `cf_zone_id` |
| Desk `billing_accounts` / tenant meta | espelho read-only |
Resolver ordem:
1. Entitlements domínio activo
2. Sessão onboarding em curso
3. `GET dns/resolve/{domain}`
4. NS lookup público → `unknown`
---
## 9. Fases de implementação
| Fase | Entregável | Estado |
|------|------------|--------|
| **V0** | Desk CF only (`cloudflare_dns.py`) | ✅ Parcial |
| **V1** | `GET /api/v1/dns/viewer/{domain}` — CF + public + planned | ✅ VM122 2026-06-25 |
| **V1b** | Desk UI unificada (`dns-viewer.js`) | ✅ VM122 2026-06-25 |
| **V2** | OpenPanel BIND records no viewer | ✅ VM122 2026-06-25 |
| **V3** | Console `/admin/dominio` | ✅ VM123 2026-06-25 |
| **V4** | Wizard painel lateral + diff planned vs actual | 📋 |
---
## 10. Critérios de aceitação
1. Staff abre domínio email Ligbox no Desk → vê **≥ MX, SPF, DKIM, DMARC, A mail** com origem «CF Ligbox».
2. Domínio **externo** (registrador) → viewer mostra **NS actuais** + registos **públicos** (`dig`), **não** preview Ligbox.
3. Domínio **Ligbox pré-NS** → viewer mostra **planned_records** + NS Cloudflare a configurar.
4. Botão «Editar na Cloudflare» visible para `ops_lead` → abre zona correcta (conta ibytera/ligit/itecnologys).
5. **Nenhum** botão «Guardar» / «Apagar registo» no viewer.
6. Gerente em `/admin` vê mesma tabela (domínio próprio) — Spec 035.
7. OpenPanel zone (Fase V2): registos BIND listados + link OpenPanel.
8. API responde ≤3s (cache 60s por domínio OK).
---
## 11. Código (alvo monorepo)
| Caminho | Função |
|---------|--------|
| `projects/ops-desk/api/app/dns_viewer.py` | Orquestrador providers |
| `projects/ops-desk/api/app/cloudflare_dns.py` | Provider CF (existente) |
| `projects/ops-desk/api/app/openpanel_dns.py` | Provider BIND (novo) |
| `projects/wizard/backend/app/services/dns_viewer.py` | Wizard session variant |
| `projects/ops-desk/frontend/assets/dns-viewer.js` | Componente partilhado Desk |
| `projects/console/frontend/.../DnsSection.tsx` | Console `/admin` |
---
## 12. Documentos relacionados
| Doc | Relação |
|-----|---------|
| [spec.md](./spec.md) | Fluxo decisão DNS wizard |
| [cloudflare-zone-provision.md](../004-onboard-funnel-events/cloudflare-zone-provision.md) | provision-zone / apply |
| [domain-manager-console-ui.md §5.5](../035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md) | UI gerente |
| [009 spec](../009-ops-audit-overview/spec.md) | Checks públicos |
| [027 spec](../027-desk-rbac-function-matrix/spec.md) | Deep-links CF / OP |
| [DNS-VIEWER-ROLLBACK](./deploy/DNS-VIEWER-ROLLBACK.md) | Rollback e feature flag |
---
## 13. Decisões Roger (2026-06-25)
| # | Decisão |
|---|---------|
| D1 | Viewer é **read-only** — edição só via link externo |
| D2 | Caminho **Ligbox** → mostrar **planned/applied** Ligbox; **externo** → mostrar **actual** público |
| D3 | OpenPanel BIND **deve** aparecer no viewer (Fase V2) — gap operacional actual |
| D4 | Um endpoint unificado `dns/viewer` — não proliferar modais CF-only |
| D5 | Mesmo componente visual Desk + Console `/admin` (design system 035) |
---
## 14. Versionamento e rollback
**Obrigatório antes de cada deploy V1+:** backup + tag git + feature flag.
| Documento | Função |
|-----------|--------|
| [deploy/DNS-VIEWER-EXEC-20260625.md](./deploy/DNS-VIEWER-EXEC-20260625.md) | Checklist deploy, ficheiros, validação |
| [deploy/DNS-VIEWER-ROLLBACK.md](./deploy/DNS-VIEWER-ROLLBACK.md) | Rollback por fase (Desk / Wizard / Console) |
| [deploy/scripts/preflight-dns-viewer.sh](./deploy/scripts/preflight-dns-viewer.sh) | Backup VM122 (+ `--wizard` / `--console`) |
| [deploy/scripts/verify-dns-viewer.sh](./deploy/scripts/verify-dns-viewer.sh) | Smoke test pós-deploy ou pós-rollback |
| [deploy/scripts/rollback-dns-viewer.sh](./deploy/scripts/rollback-dns-viewer.sh) | Rollback automatizado VM122 |
### Feature flag VM122
```bash
DNS_VIEWER_ENABLED=1 # viewer activo
DNS_VIEWER_ENABLED=0 # fallback legado /dns/cloudflare/records + UI V0
```
### Tag git recomendada
```bash
git tag -a dns-viewer-pre-v1-YYYYMMDD -m "Antes DNS Viewer V1"
```
### Endpoint legado (mantido até cutover)
`GET /api/v1/dns/cloudflare/records`**não remover** enquanto rollback não estiver validado em produção ≥7 dias.

View file

@ -0,0 +1,90 @@
# Spec 037 — Plano de implementação (4 fases FECHADAS)
**Roger · 2026-06-22** · Status: 🏗️ **CONSTRUÇÃO ACTIVA** (Fase A deploy VM112 ✅)
Todas as fases estão **definidas e fechadas** para execução. Ordem de construção obrigatória: **A → B → C → D**.
---
## Fase A — VM112 núcleo (FastAPI + wizard)
**Objectivo:** Multi-conta CF, BYO, `proj_*@ligbox.com.br`, provision-zone, apply.
| Entrega | Path / endpoint |
|---------|-----------------|
| Registry 3 contas | `dns-accounts.yaml` + `dns_account_registry.py` |
| Resolve / BYO | `GET /dns/resolve`, `POST /dns/cloudflare/connect-custom` |
| Zona cliente | `POST /dns/cloudflare/provision-zone` |
| Identidade projeto | `POST /project/provision-email`, `GET /project/{domain}` |
| UI wizard | 3 caminhos DNS + painel credenciais `proj_*` |
| Webhook 004 | `ligbox_project_email` em eventos |
**Critério done:** onboarding completo em VM112 sem Worker edge.
---
## Fase B — Worker orquestrador (1 Durable Object)
**Objectivo:** `onboard-maestro` DO por sessão; delega VM112 via HTTP; WebSocket wizard.
| Entrega | Path |
|---------|------|
| Worker | `projects/cloudflare-agents/onboard/` |
| DO | `OnboardMaestro` — estado sessão |
| Routes | `POST /session`, `WS /session/{id}` |
| Client | `vm112_client.ts` → API onboarding |
**Critério done:** wizard pode usar edge OU VM112 directo (feature flag).
---
## Fase C — Multi-agent edge (4 agentes)
**Objectivo:** Especialização + human-in-the-loop + MCP.
| Agente DO | Tools |
|-----------|-------|
| `proj-mail` | provision-email |
| `cf-zone` | provision-zone, apply, verify |
| `cf-handoff` | handoff-manager (stub → Fase D) |
| `onboard-maestro` | coordena + pausa humana |
**Critério done:** 4 passos críticos com pause/resume; eventos VM122 Spec 029.
---
## Fase D — Conta CF dedicada por cliente
**Objectivo:** `POST /accounts` Tenant + handoff gestor.
| Entrega | Endpoint VM112 |
|---------|----------------|
| Conta CF cliente | `POST /dns/cloudflare/provision-client-account` |
| Handoff | `POST /dns/cloudflare/handoff-manager` |
| Registo | `/var/lib/ligbox-wizard/cf_client_accounts/` |
**Pré-requisito:** Tenant Cloudflare Ligbox activo.
**Critério done:** zona na conta **do cliente**, não na mãe ibytera.
---
## Anexos (referência fechada)
| Doc | Conteúdo |
|-----|----------|
| [spec.md](./spec.md) | Visão geral |
| [client-cf-account-lifecycle.md](./client-cf-account-lifecycle.md) | Fase D regras |
| [project-email-identity.md](./project-email-identity.md) | Fase A identidade |
| [cf-agents-sdk-architecture.md](./cf-agents-sdk-architecture.md) | Fases B+C |
---
## Construção — sprint actual
```
[A] VM112 endpoints project + wizard UI ← AGORA
[B] Worker scaffold + wrangler
[C] Agent classes no Worker
[D] provision-client-account stub + handoff stub
```

View file

@ -0,0 +1,118 @@
# Anexo 037-C — E-mail projeto `proj_*@ligbox.com.br` (agente Ligbox)
**Parent:** [spec.md](./spec.md) · [037-B client-cf-account-lifecycle.md](./client-cf-account-lifecycle.md)
**Solicitado por:** Roger · **2026-06-22**
**Status:** 📋 Regra definida — implementação Fase 2
---
## Regra (Roger)
O **agente Ligbox** deve, para cada cliente em onboarding Cloudflare:
1. **Criar caixa real** `proj_{id}@ligbox.com.br` no Carbonio (domínio `ligbox.com.br`).
2. **Gerar senha** segura e **entregar ao cliente** (uma vez, no wizard / e-mail de boas-vindas).
3. **Referenciar este e-mail em todos os processos** ligados à nova conta e à administração dos apontamentos de email da plataforma.
Não é alias passivo — é **identidade operacional do projeto** até handoff ao `admin@{dominio_cliente}`.
---
## Ordem no fluxo (antes de CF + DNS cliente)
```mermaid
flowchart LR
A[validate-domain] --> B[provision-project-email]
B --> C[Entrega senha ao cliente]
C --> D[provision-client-account CF]
D --> E[provision-zone + apply DNS]
E --> F[account/create admin@cliente]
F --> G[handoff CF gestor]
```
| Passo | Acção | E-mail usado |
|-------|--------|--------------|
| 1 | `POST /project/provision-email` | Cria `proj_005@ligbox.com.br` |
| 2 | Resposta wizard | Senha + credenciais webmail `mail.ligbox.com.br` |
| 3 | `POST /dns/cloudflare/provision-client-account` | Membro CF = `proj_005@ligbox.com.br` |
| 4 | `POST /dns/cloudflare/apply` | `activity_log` + webhook com `ligbox_project_email` |
| 5 | `POST /account/create` | `admin@cliente.com.br` (domínio cliente) — **distinto** do proj |
| 6 | Handoff | Convite `admin@cliente` na CF; `proj_*` mantém-se suporte |
---
## Entrega da senha ao cliente
| Canal | Quando |
|-------|--------|
| **Wizard (primário)** | Painel «Credenciais do projeto» após criar `proj_*` — copiar uma vez |
| **E-mail** | Opcional: `notify_email` do wizard → template com link onboard (senha só no painel, não no email por segurança) |
| **Vault servidor** | `onboard_handoff` / vault projeto — TTL 24h; nunca logar senha em `activity_log` |
Texto sugerido no wizard:
> Use `proj_005@ligbox.com.br` para aceder à Cloudflare e acompanhar o setup.
> Webmail: https://mail.ligbox.com.br/
> Guarde a senha — não voltará a ser mostrada.
---
## Onde referenciar `ligbox_project_email`
| Sistema | Campo / uso |
|---------|-------------|
| `domain_registry` | `project_id`, `ligbox_project_email`, `ligbox_project_password_delivered_at` |
| Conta Cloudflare | Membro inicial + login convite |
| `POST /dns/cloudflare/apply` | Header interno / payload webhook `project_email` |
| `POST /account/create` | Body metadata `ligbox_project_email` (auditoria) |
| Ops webhook VM122 | `onboard.project_email` no funil 004 |
| `activity_log` | Tag `project:proj_005@ligbox.com.br` em passos DNS/CF |
| Purge / Spec 017 | Não apagar `proj_*` sem confirmação ops |
---
## Registo `domain_registry` (extensão)
```json
{
"domain": "empresa.com.br",
"project_id": "005",
"ligbox_project_email": "proj_005@ligbox.com.br",
"ligbox_project_display_name": "Projeto Empresa — empresa.com.br",
"ligbox_project_created_at": "2026-06-22T14:00:00Z",
"ligbox_project_password_delivered": true,
"portal_admin_email": "admin@empresa.com.br",
"cf_account_id": "…",
"handoff_status": "pending"
}
```
---
## API planeadas (VM112)
| Método | Path | Descrição |
|--------|------|-----------|
| POST | `/api/onboarding/project/provision-email` | Aloca `project_id`, cria `proj_*@ligbox.com.br`, regista domínio |
| GET | `/api/onboarding/project/{domain}` | Estado identidade projeto (sem senha) |
| POST | `/api/onboarding/project/reveal-password` | Uma vez por sessão — devolve senha do vault |
**Implementação Carbonio:** reutilizar `carbonio.create_account_full()` no domínio `ligbox.com.br` (já existe em VM112).
---
## Código (monorepo)
| Ficheiro | Função |
|----------|--------|
| `projects/wizard/backend/app/services/project_identity.py` | Aloca ID, cria caixa, vault senha, registo |
| `deploy/vm112-wizard/project-counter.txt` | Contador sequencial (ou SQLite futuro) |
---
## Critérios de aceitação
1. Cliente recebe `proj_{id}@ligbox.com.br` + senha antes do passo DNS Cloudflare.
2. Mesmo e-mail aparece em CF, registry, webhooks e logs de DNS.
3. `admin@{dominio}` criado no passo 5 permanece e-mail principal do **mail do cliente**.
4. BYO / registrador externo **não** criam `proj_*` (só caminho Cloudflare Ligbox).

View file

@ -0,0 +1,178 @@
# Spec 037 — DNS multi-conta Cloudflare + BYO + Registrador
**Criado:** 2026-06-22
**Solicitado por:** Roger
**Status:** 🔄 Em implementação (Fase A activa · B/C/D em construção)
**Prioridade:** P1
**VM:** 112 (wizard API `:8090`)
**Relacionado:** [004 cloudflare-zone-provision](../004-onboard-funnel-events/cloudflare-zone-provision.md) · 025 · 017 · **[dns-viewer.md](./dns-viewer.md)** · [035 §5.5](../035-ligbox-mail-bundles-foss-openpanel/domain-manager-console-ui.md)
---
## Resumo
O passo DNS do onboarding usa **três contas Cloudflare gerenciadas pela Ligbox** (ligit, itecnologys, ibytera). O wizard:
1. **Se a zona já existe** numa das 3 contas → aplica apontamentos com o token dessa conta.
2. **Se o cliente escolhe «Cloudflare Ligbox»** e a zona **ainda não existe** → o wizard **cria a zona do cliente** na conta Ligbox correcta (`provision-zone`), depois aplica MX/SPF/etc.
3. **Se o cliente não quer DNS na Ligbox** → BYO (token CF dele) ou registrador externo.
**Política:** não importar domínios alheios nem criar zonas fora do fluxo wizard; **sim** criar a zona **do cliente** na CF gerenciada pela Ligbox quando esse for o caminho escolhido.
**Evolução (Roger 2026-06-22):** cada cliente novo terá **conta Cloudflare dedicada** — e-mail inicial `proj_{id}@ligbox.com.br`, dados do domínio do cliente; após go-live, convidar gestor `admin@dominio`. Ver [client-cf-account-lifecycle.md](./client-cf-account-lifecycle.md).
**Identidade projeto (Roger 2026-06-22):** o agente **cria** `proj_{id}@ligbox.com.br` no Carbonio, **entrega senha** ao cliente e referencia em CF, DNS, registry e webhooks. Ver [project-email-identity.md](./project-email-identity.md).
---
## Três contas Ligbox (pré-cadastro)
| ID interno | E-mail admin (referência) | Ficheiro token (VM112) | `account_id` CF |
|------------|---------------------------|-------------------------|-----------------|
| `ligit` | admin@ligit.com.br | `secrets/cloudflare-ligit.token` | _configurar_ |
| `itecnologys` | admin@itecnologys.com | `secrets/cloudflare-itecnologys.token` | _configurar_ |
| `ibytera` | ibytera@gmail.com | `secrets/cloudflare-ibytera.token` | _configurar_ |
Catálogo: `deploy/vm112-wizard/dns-accounts.yaml` (não commitar tokens).
Conta **legacy** `cloudflare.token` + `cloudflare_account_id` em `config.py` → migrar para `ibytera` ou deprecar após cutover.
---
## Fluxo decisão (passo DNS)
```mermaid
flowchart TD
A[POST validate-domain] --> B[GET /dns/resolve/{domain}]
B --> C{Zona já existe em conta Ligbox?}
C -->|sim| D[apply na conta encontrada]
C -->|não| E{Cliente escolhe caminho}
E -->|Cloudflare Ligbox| F[provision-zone: cria zona do cliente na conta default]
F --> G[apply apontamentos]
E -->|BYO| H[connect-custom + apply]
E -->|Registrador| I[instructions + verify manual]
D --> V[GET dns/verify]
G --> V
H --> V
I --> V
V --> J[account/create]
```
### Regra «zona do cliente na CF Ligbox»
- **Existente:** probe `ligit → itecnologys → ibytera`; usa a conta onde a zona já está.
- **Nova (wizard):** `POST provision-zone` cria `cliente.com.br` na conta `default_provision_account` (yaml, hoje `ibytera`) — **só** quando o utilizador escolhe Cloudflare Ligbox.
- **Nunca:** criar zona sem escolha explícita Ligbox; nunca BYO/registrador criar em conta Ligbox.
---
## Endpoints novos / alterados
| Método | Path | Descrição |
|--------|------|-----------|
| GET | `/api/onboarding/dns/resolve/{domain}` | Procura zona nas 3 contas; devolve `matched_account`, `zone_id`, `paths_available` |
| POST | `/api/onboarding/dns/cloudflare/connect-custom` | Valida token BYO + zona; guarda em vault sessão |
| POST | `/api/onboarding/dns/cloudflare/apply` | **Alterado:** usa conta resolvida, BYO, ou body `account_id` |
| POST | `/api/onboarding/dns/cloudflare/provision-zone` | **Cria zona do cliente** na CF Ligbox (conta default ou existente) — só caminho Ligbox |
### Response `dns/resolve`
```json
{
"domain": "cliente.com.br",
"matched": true,
"account_id": "ligit",
"account_label": "Ligbox Ligit (admin@ligit.com.br)",
"zone_id": "…",
"zone_status": "active",
"dns_mode": "ligbox_cf_ligit",
"paths_available": ["apply_ligbox", "byo", "external"]
}
```
Se `matched: false` (zona nova):
```json
{
"matched": false,
"can_provision_ligbox": true,
"provision_account_id": "ibytera",
"paths_available": ["provision_ligbox", "byo", "external"],
"message": "Domínio novo — pode criar zona na Cloudflare gerenciada pela Ligbox ou usar BYO/registrador."
}
```
---
## BYO Cloudflare (cliente)
1. Cliente cola **API Token** (scope: DNS Edit + Zone Read **só na zona**).
2. `verify_token()` + `get_zone_by_name(domain)`.
3. Token em vault (`/var/lib/.../dns_tokens/{session_hash}`), TTL 7 dias pós-onboard.
4. `apply` com `CloudflareDNS(token=customer_token)`.
5. Purge Spec 017 **não** apaga zona BYO — só registos criados por nós (futuro: tracking).
---
## Registrador / DNS externo
Sem mudança funcional core: `GET /dns/instructions/{domain}` + verificação `dns_verify`.
Fase 2: adapters EPP/API (Gandi, GoDaddy, Registro.br homologado).
---
## DNS Viewer (read-only) — Spec 037-DNS-VIEWER
Painel unificado para **exibir** apontamentos (staff Desk + gerente Console) **sem editar** na UI.
| Caminho wizard | O viewer mostra |
|----------------|-----------------|
| **Cloudflare Ligbox** | Registos **planeados** (pré-NS) ou **aplicados** (pós-apply) + NS CF |
| **BYO / Registrador / externo** | DNS **actual** (público + instructions) — **não** preview Ligbox |
Documento completo: **[dns-viewer.md](./dns-viewer.md)** — endpoint `GET /api/v1/dns/viewer/{domain}`, links editar (CF / OpenPanel / registrador), fases V0V4.
---
## Código
| Caminho monorepo | Deploy VM112 |
|------------------|--------------|
| `projects/wizard/backend/app/services/dns_account_registry.py` | `/opt/ligbox-wizard/backend/app/services/` |
| `deploy/vm112-wizard/dns-accounts.yaml` | `/opt/ligbox-wizard/dns-accounts.yaml` |
| `deploy/vm112-wizard/dns-accounts.yaml.example` | exemplo versionado |
---
## Critérios de aceitação (Fase 1)
1. Domínio existente na conta `ibytera``resolve` retorna `matched` + `apply` OK.
2. Domínio **novo** + caminho Ligbox → `provision-zone` cria zona na conta default + `apply` OK.
3. BYO com token válido → `apply` na conta do cliente.
4. Caminho registrador → sem `provision-zone` Ligbox.
5. Tokens em `secrets/` — nunca no Git.
---
## Decisões Roger (2026-06-22, corrigido)
| # | Regra |
|---|--------|
| 1 | Não importar domínios alheios nas contas Ligbox |
| 2 | **Sim** criar zona **do cliente** na CF gerenciada Ligbox via wizard (escolha explícita) |
| 3 | Conta para zona nova: `default_provision_account` no yaml (hoje `ibytera`) |
| 4 | BYO / registrador = DNS fora das contas Ligbox |
---
## Fase 2 — Conta CF dedicada por cliente
Ver [client-cf-account-lifecycle.md](./client-cf-account-lifecycle.md):
| Fase | E-mail Cloudflare | Quando |
|------|-------------------|--------|
| A — Onboarding | `proj_{id}@ligbox.com.br` | Criação conta + zona |
| B — Handoff | `admin@{dominio_cliente}` convidado como gestor | DNS + email + infra OK |
**Orquestração agentica (edge):** [cf-agents-sdk-architecture.md](./cf-agents-sdk-architecture.md) — Cloudflare Agents SDK (Durable Objects, multi-agent, human-in-the-loop) + roster Spec 029 A0A7.

View file

@ -0,0 +1,57 @@
# Spec 037 — Tasks (construção)
**Status:** 🏗️ Fases AD fechadas · ver [implementation-phases.md](./implementation-phases.md)
---
## Fase A — VM112 núcleo
- [x] Multi-conta resolve + BYO + provision-zone
- [x] `project_identity.py` monorepo + VM112
- [x] `POST /project/provision-email` VM112 ✅ smoke `proj_001@`
- [x] `GET /project/{domain}` VM112
- [x] UI painel credenciais `proj_*`
- [x] Webhook `ligbox_project_email` (apply + account + project)
- [ ] Tokens ligit + itecnologys (Roger)
- [ ] E2E smoke completo wizard público
## Fase B — Worker orquestrador
- [x] `projects/cloudflare-agents/onboard/` wrangler + DO scaffold
- [x] `OnboardMaestro` estado sessão
- [x] HTTP client VM112
- [x] Deploy Worker — https://ligbox-onboard-agents.ibytera.workers.dev
- [x] Worker → VM112 proj-mail E2E OK
- [ ] Rota `agents.ligbox.com.br` (opcional)
- [ ] Wizard UI teste manual telas (Roger)
- [ ] Feature flag wizard `use_edge_orchestrator`
## Fase C — Multi-agent edge
- [x] DO stubs `ProjMailAgent`, `CfZoneAgent`, `CfHandoffAgent`
- [x] Maestro delegação REST (`/run/proj-mail`, `/run/cf-zone`)
- [ ] WebSocket wizard ↔ edge
- [ ] Inbox Spec 029 handoff ack
## Fase D — Conta CF dedicada
- [ ] Tenant CF Ligbox confirmado
- [x] `provision-client-account` (fallback zona partilhada)
- [x] `handoff-manager` (stub registo)
- [x] `cf_client_accounts/` registry
## Backlog
- [ ] Registradores EPP (Fase E)
- [ ] Routing conta por marca (ligit/itecnologys)
## DNS Viewer (dns-viewer.md)
- [x] V0 Desk CF only — `cloudflare_dns.py` + Overview modal
- [x] Spec rollback + scripts deploy (`deploy/DNS-VIEWER-*.md`, `deploy/scripts/*`)
- [x] V1 `GET /api/v1/dns/viewer/{domain}` — CF + public + planned (deploy VM122 2026-06-25)
- [x] V1b Desk UI unificada (`dns-viewer.js` + Overview/IaaS modal)
- [x] V2 OpenPanel BIND records (`openpanel_dns.py`) — VM122 2026-06-25
- [x] V3 Console `/admin/dominio` consume viewer API — VM123 2026-06-25
- [ ] V4 Wizard painel lateral + diff planned vs actual
- [ ] RBAC `dns.viewer.read` formalizar Spec 027

View file

@ -0,0 +1,40 @@
# Spec 037 — Checklist wizard (validação 2026-06-22)
## Infra OK (automático)
| Componente | URL / teste | Status |
|------------|-------------|--------|
| Wizard público | https://onboard.ligbox.com.br/ | 200 ✅ |
| API VM112 | `/api/onboarding/health` | ok ✅ |
| DNS resolve | `/dns/resolve/{domain}` | ok ✅ |
| Project email | `POST /project/provision-email` | proj_001..003 ✅ |
| Worker edge | `ligbox-onboard-agents.ibytera.workers.dev/health` | ok ✅ |
| Worker → VM112 | `POST .../run/proj-mail` | ok ✅ |
| Frontend build | `index-Bsaznm2Y.js` (14:19 UTC) | deployed ✅ |
## Telas wizard — verificar manualmente (Roger)
URL: **https://onboard.ligbox.com.br/**
| # | Passo | O que ver | Esperado |
|---|-------|-----------|----------|
| 1 | Domínio | Inserir domínio teste | Avança passo DNS |
| 2 | DNS automático | Modo simples | Cria `proj_*@ligbox.com.br` + painel credenciais |
| 3 | Credenciais projeto | Painel azul | E-mail `proj_XXX@ligbox.com.br` + botão senha |
| 4 | Cloudflare Ligbox | Botão principal | provision-zone + apply |
| 5 | BYO | Modo técnico | Campo API Token |
| 6 | Registrador | Modo técnico | Instruções manuais |
| 7 | Conta admin | Passo 2 | Formulário admin@dominio |
| 8 | Conclusão | Passo 4 | Resumo + webmail |
## Edge (opcional, ainda não ligado ao UI)
Wizard usa **VM112 directo**. Worker disponível para Fase B flag futura.
```bash
# Teste edge manual
SESSION=test123
curl -X POST "https://ligbox-onboard-agents.ibytera.workers.dev/api/session/$SESSION/init" \
-H "Content-Type: application/json" -d "{\"sessionId\":\"$SESSION\",\"domain\":\"meu.com.br\"}"
curl -X POST "https://ligbox-onboard-agents.ibytera.workers.dev/api/session/$SESSION/run/proj-mail"
```