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:
parent
7e4920a4bb
commit
038fb8f7ce
54 changed files with 8074 additions and 5 deletions
36
deploy/vm112-wizard/DNS-VIEWER-V4.md
Normal file
36
deploy/vm112-wizard/DNS-VIEWER-V4.md
Normal 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.
|
||||
134
deploy/vm112-wizard/deploy-037-construction.py
Normal file
134
deploy/vm112-wizard/deploy-037-construction.py
Normal 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")
|
||||
125
deploy/vm112-wizard/deploy-037-vm112.py
Normal file
125
deploy/vm112-wizard/deploy-037-vm112.py
Normal 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")
|
||||
36
deploy/vm112-wizard/dns-accounts.yaml.example
Normal file
36
deploy/vm112-wizard/dns-accounts.yaml.example
Normal 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"
|
||||
58
deploy/vm112-wizard/fix-choose-portal-dns.py
Normal file
58
deploy/vm112-wizard/fix-choose-portal-dns.py
Normal 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")
|
||||
160
deploy/vm112-wizard/fix-provision-zone.py
Normal file
160
deploy/vm112-wizard/fix-provision-zone.py
Normal 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")
|
||||
123
deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py
Normal file
123
deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py
Normal 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")
|
||||
315
deploy/vm112-wizard/frontend-dns037.patch.py
Normal file
315
deploy/vm112-wizard/frontend-dns037.patch.py
Normal 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")
|
||||
165
deploy/vm112-wizard/frontend-provision037b.patch.py
Normal file
165
deploy/vm112-wizard/frontend-provision037b.patch.py
Normal 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")
|
||||
56
deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py
Normal file
56
deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py
Normal 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")
|
||||
341
deploy/vm112-wizard/onboarding-dns037.patch.py
Normal file
341
deploy/vm112-wizard/onboarding-dns037.patch.py
Normal 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")
|
||||
77
deploy/vm112-wizard/patch-wizard-project-ui.py
Normal file
77
deploy/vm112-wizard/patch-wizard-project-ui.py
Normal 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")
|
||||
119
deploy/vm112-wizard/provision-zone037b.patch.py
Normal file
119
deploy/vm112-wizard/provision-zone037b.patch.py
Normal 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")
|
||||
345
projects/ops-desk/api/app/dns_viewer.py
Normal file
345
projects/ops-desk/api/app/dns_viewer.py
Normal 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,
|
||||
}
|
||||
66
projects/ops-desk/api/app/domain_console_routes.py
Normal file
66
projects/ops-desk/api/app/domain_console_routes.py
Normal 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("."))
|
||||
316
projects/ops-desk/api/app/domain_console_sandbox.py
Normal file
316
projects/ops-desk/api/app/domain_console_sandbox.py
Normal 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,
|
||||
}
|
||||
175
projects/ops-desk/api/app/domain_console_sandbox_routes.py
Normal file
175
projects/ops-desk/api/app/domain_console_sandbox_routes.py
Normal 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")
|
||||
134
projects/ops-desk/api/app/domain_console_sandbox_store.py
Normal file
134
projects/ops-desk/api/app/domain_console_sandbox_store.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
138
projects/ops-desk/api/app/openpanel_dns.py
Normal file
138
projects/ops-desk/api/app/openpanel_dns.py
Normal 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",
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
204
projects/ops-desk/frontend/assets/dns-viewer.js
Normal file
204
projects/ops-desk/frontend/assets/dns-viewer.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 100–500 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) |
|
||||
144
specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md
Normal file
144
specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md
Normal 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_ |
|
||||
|
|
@ -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 | 6–7 (§4) | 8–10 (§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` vê `/ops`; `commercial` vê `/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 A0–A7, 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 A0–A7 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`
|
||||
|
|
@ -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`
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
696
specs/035-ligbox-mail-bundles-foss-openpanel/spec.md
Normal file
696
specs/035-ligbox-mail-bundles-foss-openpanel/spec.md
Normal 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. 100–200) | 1 incluído | **R$ 249/mês** |
|
||||
| `ligbox-mail-business` | **Ligbox Mail Business** | 25 | 30 GB | 200 GB (config. 100–300) | 1 incluído | **R$ 549/mês** |
|
||||
| `ligbox-mail-enterprise` | **Ligbox Mail Enterprise** | 50 | 50 GB | 300 GB (config. 100–500) | 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:**
|
||||
|
||||
- **20–45% 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 | 20–50 |
|
||||
| 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 100–500 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 4–8/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 1–3: 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 |
|
||||
92
specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md
Normal file
92
specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md
Normal 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)
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 (A0–A7)
|
||||
|
||||
| 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` (A0–A7)
|
||||
- Ligbox Spec 037 `project-email-identity.md`
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
| V2–V4 | ⏳ 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
|
||||
|
|
@ -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`
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 ==="
|
||||
|
|
@ -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
|
||||
369
specs/037-dns-multi-cloudflare-orchestration/dns-viewer.md
Normal file
369
specs/037-dns-multi-cloudflare-orchestration/dns-viewer.md
Normal 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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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).
|
||||
178
specs/037-dns-multi-cloudflare-orchestration/spec.md
Normal file
178
specs/037-dns-multi-cloudflare-orchestration/spec.md
Normal 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 V0–V4.
|
||||
|
||||
---
|
||||
|
||||
## 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 A0–A7.
|
||||
57
specs/037-dns-multi-cloudflare-orchestration/tasks.md
Normal file
57
specs/037-dns-multi-cloudflare-orchestration/tasks.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Spec 037 — Tasks (construção)
|
||||
|
||||
**Status:** 🏗️ Fases A–D 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
|
||||
|
|
@ -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"
|
||||
```
|
||||
Loading…
Reference in a new issue