Add purge authorization codes for protected domains (myvexx.com).
Root generates single-use codes in Infra with root password; Serviços purge requires code plus root password for PURGE_EXTRA_AUTH_DOMAINS.
This commit is contained in:
parent
0ee4845818
commit
a39618afb8
8 changed files with 446 additions and 8 deletions
|
|
@ -21,6 +21,7 @@ from app import crm_leads, integration_health
|
|||
from app.cloudflare_dns import fetch_domain_dns
|
||||
from app.modules.routes import router as modules_router
|
||||
from app.vm112_domains_routes import router as vm112_domains_router
|
||||
from app.purge_auth_routes import router as purge_auth_router
|
||||
from app.carbonio_release_routes import router as carbonio_release_router
|
||||
from app.migration.router import router as migration_router
|
||||
from app.billing_routes import router as billing_router
|
||||
|
|
@ -123,6 +124,7 @@ app.include_router(assist_router)
|
|||
app.include_router(crm_router)
|
||||
app.include_router(modules_router)
|
||||
app.include_router(vm112_domains_router)
|
||||
app.include_router(purge_auth_router)
|
||||
app.include_router(security_router)
|
||||
app.include_router(carbonio_release_router)
|
||||
app.include_router(migration_router)
|
||||
|
|
@ -175,8 +177,10 @@ def init_db():
|
|||
migration_store.init_schema(conn)
|
||||
billing_store.init_schema(conn)
|
||||
from app.vm112_purge_jobs import init_purge_jobs_schema
|
||||
from app.purge_auth_codes import init_purge_auth_schema
|
||||
|
||||
init_purge_jobs_schema(conn)
|
||||
init_purge_auth_schema(conn)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
conn.commit()
|
||||
|
|
|
|||
196
projects/ops-desk/api/app/purge_auth_codes.py
Normal file
196
projects/ops-desk/api/app/purge_auth_codes.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Códigos de autorização purge — domínios protegidos (Spec 017 extensão)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from app import auth, vm112_domains
|
||||
|
||||
DEFAULT_TTL_HOURS = int(__import__("os").getenv("PURGE_AUTH_CODE_TTL_HOURS", "24"))
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return _now().isoformat()
|
||||
|
||||
|
||||
def init_purge_auth_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS purge_auth_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
domain TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
used_by TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_purge_auth_codes_domain_active
|
||||
ON purge_auth_codes(domain, expires_at, used_at)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def normalize_code(code: str) -> str:
|
||||
return code.upper().replace("-", "").replace(" ", "").strip()
|
||||
|
||||
|
||||
def format_code_display(raw: str) -> str:
|
||||
raw = normalize_code(raw)
|
||||
if len(raw) == 8:
|
||||
return f"{raw[:4]}-{raw[4:]}"
|
||||
return raw
|
||||
|
||||
|
||||
def generate_raw_code() -> str:
|
||||
return secrets.token_hex(4).upper()
|
||||
|
||||
|
||||
def requires_extra_auth(domain: str) -> bool:
|
||||
return domain.lower().strip() in vm112_domains.PURGE_EXTRA_AUTH_DOMAINS
|
||||
|
||||
|
||||
def create_code(
|
||||
conn: sqlite3.Connection,
|
||||
domain: str,
|
||||
root_password: str,
|
||||
created_by: str,
|
||||
note: str = "",
|
||||
ttl_hours: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
domain = domain.lower().strip()
|
||||
if not vm112_domains.verify_root_password(conn, root_password):
|
||||
raise ValueError("Senha Root incorrecta")
|
||||
if domain in vm112_domains.PURGE_BLOCKLIST:
|
||||
raise ValueError(f"Domínio {domain} está na blocklist absoluta — purge proibido")
|
||||
if not requires_extra_auth(domain):
|
||||
raise ValueError(f"Domínio {domain} não exige código extra de autorização")
|
||||
|
||||
ttl = ttl_hours if ttl_hours is not None else DEFAULT_TTL_HOURS
|
||||
ttl = max(1, min(int(ttl), 168))
|
||||
raw = generate_raw_code()
|
||||
display = format_code_display(raw)
|
||||
now = _now()
|
||||
expires = now + timedelta(hours=ttl)
|
||||
code_id = uuid.uuid4().hex[:16]
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO purge_auth_codes (
|
||||
id, domain, code_hash, note, created_by, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
code_id,
|
||||
domain,
|
||||
auth.hash_password(normalize_code(raw)),
|
||||
(note or "").strip() or None,
|
||||
created_by,
|
||||
now.isoformat(),
|
||||
expires.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": code_id,
|
||||
"domain": domain,
|
||||
"code": display,
|
||||
"expires_at": expires.isoformat(),
|
||||
"ttl_hours": ttl,
|
||||
"note": note or None,
|
||||
}
|
||||
|
||||
|
||||
def validate_and_consume(
|
||||
conn: sqlite3.Connection,
|
||||
domain: str,
|
||||
code: str,
|
||||
used_by: str | None = None,
|
||||
) -> bool:
|
||||
domain = domain.lower().strip()
|
||||
if not requires_extra_auth(domain):
|
||||
return True
|
||||
normalized = normalize_code(code or "")
|
||||
if len(normalized) < 6:
|
||||
return False
|
||||
|
||||
now = _now_iso()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, code_hash FROM purge_auth_codes
|
||||
WHERE domain = ? AND used_at IS NULL AND expires_at > ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(domain, now),
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
if auth.verify_password(normalized, row["code_hash"]):
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE purge_auth_codes
|
||||
SET used_at = ?, used_by = ?
|
||||
WHERE id = ? AND used_at IS NULL
|
||||
""",
|
||||
(now, used_by, row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_codes(conn: sqlite3.Connection, active_only: bool = True, limit: int = 50) -> list[dict[str, Any]]:
|
||||
limit = max(1, min(int(limit), 200))
|
||||
now = _now_iso()
|
||||
if active_only:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, domain, note, created_by, created_at, expires_at, used_at, used_by
|
||||
FROM purge_auth_codes
|
||||
WHERE used_at IS NULL AND expires_at > ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(now, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, domain, note, created_by, created_at, expires_at, used_at, used_by
|
||||
FROM purge_auth_codes
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"domain": row["domain"],
|
||||
"note": row["note"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
"used_at": row["used_at"],
|
||||
"used_by": row["used_by"],
|
||||
"active": row["used_at"] is None and row["expires_at"] > now,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
75
projects/ops-desk/api/app/purge_auth_routes.py
Normal file
75
projects/ops-desk/api/app/purge_auth_routes.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Rotas Infra — códigos autorização purge (root / super_admin)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import auth, purge_auth_codes, vm112_domains
|
||||
from app.permissions import can_manage_users
|
||||
|
||||
router = APIRouter(prefix="/api/v1/infra", tags=["infra-purge-auth"])
|
||||
|
||||
|
||||
class PurgeAuthCodeCreate(BaseModel):
|
||||
domain: str = Field(..., min_length=3)
|
||||
root_password: str = Field(..., min_length=1)
|
||||
note: str = Field("", max_length=500)
|
||||
ttl_hours: int = Field(24, ge=1, le=168)
|
||||
|
||||
|
||||
def _require_root_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||
if not can_manage_users(user.role):
|
||||
raise HTTPException(403, "Apenas super_admin (root) pode gerar códigos de purge")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/purge-auth-codes")
|
||||
def list_purge_auth_codes(
|
||||
active_only: bool = Query(True),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
user: auth.DeskUser = Depends(_require_root_admin),
|
||||
):
|
||||
conn = auth.db()
|
||||
try:
|
||||
purge_auth_codes.init_purge_auth_schema(conn)
|
||||
codes = purge_auth_codes.list_codes(conn, active_only=active_only, limit=limit)
|
||||
return {
|
||||
"codes": codes,
|
||||
"extra_auth_domains": sorted(vm112_domains.PURGE_EXTRA_AUTH_DOMAINS),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/purge-auth-domains")
|
||||
def list_purge_auth_domains(
|
||||
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||
):
|
||||
return {
|
||||
"domains": sorted(vm112_domains.PURGE_EXTRA_AUTH_DOMAINS),
|
||||
"can_generate": can_manage_users(user.role),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/purge-auth-codes")
|
||||
def create_purge_auth_code(
|
||||
body: PurgeAuthCodeCreate,
|
||||
user: auth.DeskUser = Depends(_require_root_admin),
|
||||
):
|
||||
conn = auth.db()
|
||||
try:
|
||||
purge_auth_codes.init_purge_auth_schema(conn)
|
||||
result = purge_auth_codes.create_code(
|
||||
conn,
|
||||
body.domain,
|
||||
body.root_password,
|
||||
user.username,
|
||||
body.note,
|
||||
ttl_hours=body.ttl_hours,
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -16,6 +16,9 @@ VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-cha
|
|||
|
||||
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
|
||||
|
||||
# Exige código de autorização gerado em Infra (root) — além de senha Root no purge.
|
||||
PURGE_EXTRA_AUTH_DOMAINS = frozenset({"myvexx.com"})
|
||||
|
||||
VM112_PURGE_STEP_LABELS = (
|
||||
"Contas Carbonio (zmprov da)",
|
||||
"Domínio Carbonio (zmprov dd)",
|
||||
|
|
@ -48,6 +51,10 @@ def verify_root_password(conn: sqlite3.Connection, password: str) -> bool:
|
|||
return auth.verify_password(password, row["password_hash"])
|
||||
|
||||
|
||||
def requires_purge_extra_auth(domain: str) -> bool:
|
||||
return domain.lower().strip() in PURGE_EXTRA_AUTH_DOMAINS
|
||||
|
||||
|
||||
def delete_carbonio_account(email: str) -> dict[str, Any]:
|
||||
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
||||
email = email.lower().strip()
|
||||
|
|
@ -92,7 +99,11 @@ def get_domain(domain: str) -> dict[str, Any]:
|
|||
headers=_vm112_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
data = r.json()
|
||||
if isinstance(data, dict):
|
||||
data["purge_extra_auth_required"] = requires_purge_extra_auth(domain)
|
||||
data["purge_blocked"] = domain in PURGE_BLOCKLIST
|
||||
return data
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ router = APIRouter(prefix="/api/v1/vm112", tags=["vm112-domains"])
|
|||
class DomainPurgeRequest(BaseModel):
|
||||
confirm_domain: str = Field(..., min_length=3)
|
||||
root_password: str = Field(..., min_length=1)
|
||||
purge_auth_code: str = Field("", max_length=32)
|
||||
|
||||
|
||||
def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||
|
|
@ -33,12 +34,36 @@ def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth
|
|||
return user
|
||||
|
||||
|
||||
def _validate_purge_request(domain: str, body: DomainPurgeRequest) -> str:
|
||||
def _validate_purge_request(
|
||||
domain: str,
|
||||
body: DomainPurgeRequest,
|
||||
conn: sqlite3.Connection | None = None,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
domain = domain.lower().strip()
|
||||
if domain in vm112_domains.PURGE_BLOCKLIST:
|
||||
raise HTTPException(400, f"Domínio {domain} está protegido contra purge")
|
||||
if body.confirm_domain.lower().strip() != domain:
|
||||
raise HTTPException(400, "Confirmação do domínio não coincide")
|
||||
if vm112_domains.requires_purge_extra_auth(domain):
|
||||
if not body.purge_auth_code or not body.purge_auth_code.strip():
|
||||
raise HTTPException(
|
||||
403,
|
||||
f"Domínio {domain} exige código de autorização — gere em Infra (root) e use na conferência",
|
||||
)
|
||||
from app import purge_auth_codes
|
||||
|
||||
db = conn or auth.db()
|
||||
close_conn = conn is None
|
||||
try:
|
||||
purge_auth_codes.init_purge_auth_schema(db)
|
||||
if not purge_auth_codes.validate_and_consume(
|
||||
db, domain, body.purge_auth_code, used_by=username
|
||||
):
|
||||
raise HTTPException(403, "Código de autorização purge inválido ou expirado")
|
||||
finally:
|
||||
if close_conn:
|
||||
db.close()
|
||||
return domain
|
||||
|
||||
|
||||
|
|
@ -70,7 +95,7 @@ def purge_vm112_domain(
|
|||
body: DomainPurgeRequest,
|
||||
user: auth.DeskUser = Depends(_require_admin),
|
||||
):
|
||||
domain = _validate_purge_request(domain, body)
|
||||
domain = _validate_purge_request(domain, body, username=user.username)
|
||||
conn = auth.db()
|
||||
try:
|
||||
if not vm112_domains.verify_root_password(conn, body.root_password):
|
||||
|
|
@ -104,7 +129,7 @@ def purge_vm112_domain_stream(
|
|||
user: auth.DeskUser = Depends(_require_admin),
|
||||
):
|
||||
"""SSE — progresso purge em tempo real (Fase 2 Spec 017)."""
|
||||
domain = _validate_purge_request(domain, body)
|
||||
domain = _validate_purge_request(domain, body, username=user.username)
|
||||
return StreamingResponse(
|
||||
purge_sse_generator(domain, body.root_password, user.username),
|
||||
media_type="text/event-stream",
|
||||
|
|
@ -123,7 +148,7 @@ def start_purge_job(
|
|||
user: auth.DeskUser = Depends(_require_admin),
|
||||
):
|
||||
"""Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik)."""
|
||||
domain = _validate_purge_request(domain, body)
|
||||
domain = _validate_purge_request(domain, body, username=user.username)
|
||||
try:
|
||||
job_id = start_job(domain, body.root_password, user.username)
|
||||
except PurgeJobConflictError as exc:
|
||||
|
|
|
|||
|
|
@ -3855,6 +3855,11 @@ async function renderInfra() {
|
|||
</div>
|
||||
<p class="health-card-hint">Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Códigos autorização purge</h3>
|
||||
<p class="health-card-hint">Domínios protegidos (ex.: <code>myvexx.com</code>) exigem código único gerado aqui pelo <strong>root</strong> — válido para conferência / purge em Serviços.</p>
|
||||
<div id="purge-auth-infra-panel"><p class="loading">A carregar…</p></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>OpenPanel API — Re-engenharia Ligbox</h3>
|
||||
<p class="health-card-hint">Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup automático.</p>
|
||||
|
|
@ -3883,11 +3888,97 @@ async function renderInfra() {
|
|||
document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra());
|
||||
document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
|
||||
document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest());
|
||||
await renderPurgeAuthInfraPanel();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPurgeAuthInfraPanel() {
|
||||
const panel = document.getElementById('purge-auth-infra-panel');
|
||||
if (!panel) return;
|
||||
try {
|
||||
const meta = await api('/v1/infra/purge-auth-domains');
|
||||
const domains = (meta.domains || []).map((d) => `<code>${esc(d)}</code>`).join(', ') || '—';
|
||||
const canGen = meta.can_generate && typeof canManageUsers === 'function' && canManageUsers();
|
||||
let codesHtml = '';
|
||||
if (canGen) {
|
||||
const data = await api('/v1/infra/purge-auth-codes?limit=20');
|
||||
const rows = (data.codes || []).map((c) => `
|
||||
<tr>
|
||||
<td>${esc(c.domain)}</td>
|
||||
<td>${esc(c.note || '—')}</td>
|
||||
<td>${fmtDate(c.expires_at)}</td>
|
||||
<td>${esc(c.created_by || '—')}</td>
|
||||
</tr>`).join('');
|
||||
codesHtml = `
|
||||
<form id="purge-auth-generate-form" class="purge-auth-form">
|
||||
<label>Domínio</label>
|
||||
<input type="text" id="purge-auth-domain" class="cf-select" placeholder="myvexx.com" required />
|
||||
<label>Nota (conferência / ticket)</label>
|
||||
<input type="text" id="purge-auth-note" class="cf-select" placeholder="Autorizado em call com Roger" />
|
||||
<label>Validade (horas)</label>
|
||||
<input type="number" id="purge-auth-ttl" class="cf-select" value="24" min="1" max="168" />
|
||||
<label>Senha Root</label>
|
||||
<input type="password" id="purge-auth-root-pwd" class="cf-select" autocomplete="current-password" required />
|
||||
<button type="submit" class="btn secondary">Gerar código</button>
|
||||
</form>
|
||||
<p id="purge-auth-gen-msg" class="ticket-meta"></p>
|
||||
<div id="purge-auth-generated" class="purge-auth-generated hidden"></div>
|
||||
<h4 style="margin-top:1rem">Códigos activos</h4>
|
||||
<table class="purge-history-table">
|
||||
<thead><tr><th>Domínio</th><th>Nota</th><th>Expira</th><th>Por</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="4">Nenhum código activo</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
} else {
|
||||
codesHtml = '<p class="ticket-meta">Apenas <strong>super_admin (root)</strong> gera códigos. Peça o código ao root antes do purge em Serviços.</p>';
|
||||
}
|
||||
panel.innerHTML = `
|
||||
<p><strong>Domínios protegidos:</strong> ${domains}</p>
|
||||
${codesHtml}`;
|
||||
const form = panel.querySelector('#purge-auth-generate-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const msg = panel.querySelector('#purge-auth-gen-msg');
|
||||
const out = panel.querySelector('#purge-auth-generated');
|
||||
const domain = panel.querySelector('#purge-auth-domain')?.value?.trim() || '';
|
||||
const note = panel.querySelector('#purge-auth-note')?.value?.trim() || '';
|
||||
const ttl = parseInt(panel.querySelector('#purge-auth-ttl')?.value || '24', 10);
|
||||
const rootPwd = panel.querySelector('#purge-auth-root-pwd')?.value || '';
|
||||
if (!domain || !rootPwd) {
|
||||
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
|
||||
return;
|
||||
}
|
||||
if (msg) msg.textContent = 'A gerar…';
|
||||
try {
|
||||
const res = await api('/v1/infra/purge-auth-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
root_password: rootPwd,
|
||||
note,
|
||||
ttl_hours: ttl,
|
||||
}),
|
||||
});
|
||||
if (msg) msg.textContent = 'Código gerado — copie agora (não será mostrado de novo).';
|
||||
if (out) {
|
||||
out.classList.remove('hidden');
|
||||
out.innerHTML = `
|
||||
<p><strong>Código:</strong> <code class="purge-auth-code-display">${esc(res.code)}</code></p>
|
||||
<p class="ticket-meta">Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}</p>`;
|
||||
}
|
||||
panel.querySelector('#purge-auth-root-pwd').value = '';
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = e.message || 'Falha ao gerar código';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(options = {}) {
|
||||
const { poll = false } = options;
|
||||
await loadHealth();
|
||||
|
|
|
|||
|
|
@ -550,10 +550,14 @@ const DeskServices = (() => {
|
|||
}
|
||||
}
|
||||
|
||||
async function pollPurgeJob(domain, confirmDomain, rootPassword) {
|
||||
async function pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode = '') {
|
||||
const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirm_domain: confirmDomain, root_password: rootPassword }),
|
||||
body: JSON.stringify({
|
||||
confirm_domain: confirmDomain,
|
||||
root_password: rootPassword,
|
||||
purge_auth_code: purgeAuthCode || '',
|
||||
}),
|
||||
}, 60000);
|
||||
const jobId = start.job_id;
|
||||
if (!jobId) throw new Error('Job purge não iniciado');
|
||||
|
|
@ -731,17 +735,23 @@ const DeskServices = (() => {
|
|||
const msg = document.getElementById('vm112-purge-msg');
|
||||
const confirmDomain = document.getElementById('vm112-purge-confirm')?.value?.trim() || '';
|
||||
const rootPassword = document.getElementById('vm112-purge-root-pwd')?.value || '';
|
||||
const purgeAuthCode = document.getElementById('vm112-purge-auth-code')?.value?.trim() || '';
|
||||
const needsAuthCode = Boolean(document.getElementById('vm112-purge-auth-code'));
|
||||
if (!confirmDomain || !rootPassword) {
|
||||
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
|
||||
return;
|
||||
}
|
||||
if (needsAuthCode && !purgeAuthCode) {
|
||||
if (msg) msg.textContent = 'Este domínio exige código de autorização — gere em Infra (root).';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`PURGE definitivo de ${domain}?`)) return;
|
||||
const btn = document.getElementById('vm112-purge-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'A apagar…'; }
|
||||
if (msg) { msg.textContent = 'A executar purge…'; msg.classList.remove('vm112-purge-success'); }
|
||||
initPurgeTimelineRunning();
|
||||
try {
|
||||
const done = await pollPurgeJob(domain, confirmDomain, rootPassword);
|
||||
const done = await pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode);
|
||||
stopPurgeElapsed();
|
||||
showPurgeSuccess(done, domain);
|
||||
return;
|
||||
|
|
@ -811,6 +821,14 @@ const DeskServices = (() => {
|
|||
.map((a) => `<li>${esc(a)}</li>`).join('') || '<li class="muted">Nenhuma</li>';
|
||||
const cf = d.cloudflare_zone;
|
||||
const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera';
|
||||
const extraAuth = Boolean(d.purge_extra_auth_required);
|
||||
const authCodeField = extraAuth
|
||||
? `
|
||||
<p class="vm112-purge-warn">Domínio protegido — exige <strong>código de autorização</strong> gerado em Infra (root).</p>
|
||||
<label>Código autorização purge</label>
|
||||
<input type="text" id="vm112-purge-auth-code" class="vm112-purge-input" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"/>
|
||||
`
|
||||
: '';
|
||||
body.innerHTML = `
|
||||
<div class="modal-section">
|
||||
<h4>Serviço: E-mail Tenant</h4>
|
||||
|
|
@ -835,6 +853,7 @@ const DeskServices = (() => {
|
|||
<input type="text" id="vm112-purge-confirm" class="vm112-purge-input" placeholder="${esc(domain)}" autocomplete="off"/>
|
||||
<label>Senha Root</label>
|
||||
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
|
||||
${authCodeField}
|
||||
<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>
|
||||
<p id="vm112-purge-msg" class="ticket-meta"></p>
|
||||
</div>`;
|
||||
|
|
|
|||
|
|
@ -3888,6 +3888,23 @@ button.health-card {
|
|||
overflow-y: auto;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.purge-auth-generated {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(46, 125, 50, 0.12);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.purge-auth-generated.hidden { display: none; }
|
||||
.purge-auth-code-display {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.purge-auth-form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
max-width: 28rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.purge-history-removed {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted, #6b7280);
|
||||
|
|
|
|||
Loading…
Reference in a new issue