diff --git a/README.md b/README.md
index 561a451..4538ac3 100644
--- a/README.md
+++ b/README.md
@@ -268,7 +268,7 @@ Ou gerar uma senha nova automaticamente:
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
```
-O script atualiza o usuário `admin` no PostgreSQL, ativa ele como `superadmin`, salva `ADMIN_PASSWORD` em `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
+O script atualiza o usuário `admin` no PostgreSQL com hash bcrypt, ativa ele como `superadmin`, remove qualquer `ADMIN_PASSWORD` legado em texto puro de `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
### Atualização automática pelo Git
@@ -779,7 +779,7 @@ Or generate a new password automatically:
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
```
-The script updates the `admin` user in PostgreSQL, enables it as `superadmin`, saves `ADMIN_PASSWORD` in `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
+The script updates the `admin` user in PostgreSQL with a bcrypt hash, enables it as `superadmin`, removes any legacy plaintext `ADMIN_PASSWORD` from `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
### Automatic Git update
diff --git a/admin/assets/app.css b/admin/assets/app.css
index 7419ef5..651c41d 100644
--- a/admin/assets/app.css
+++ b/admin/assets/app.css
@@ -649,3 +649,53 @@ select:disabled {
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
+
+/* --- Bot sales workspace --- */
+#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
+.bot-hero{
+ position:relative;overflow:hidden;margin-bottom:18px;padding:26px;border:1px solid rgba(139,92,246,.22);border-radius:28px;
+ background:
+ radial-gradient(circle at 86% 4%,rgba(124,92,255,.30),transparent 34%),
+ radial-gradient(circle at 12% 100%,rgba(34,211,238,.12),transparent 38%),
+ linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
+ box-shadow:0 24px 70px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.04);
+}
+.bot-hero::after{content:"";position:absolute;right:-70px;top:-90px;width:260px;height:260px;border:1px solid rgba(255,255,255,.06);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.018),0 0 0 68px rgba(255,255,255,.012);pointer-events:none;}
+.bot-hero-copy,.bot-hero-actions,.bot-overview-grid{position:relative;z-index:1;}
+.bot-hero-copy{max-width:620px;}
+.bot-eyebrow,.bot-section-heading>div>span{display:block;color:#a997ff;font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase;}
+.bot-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em;}
+.bot-hero p,.bot-section-heading p,.bot-card-heading p{color:var(--muted);font-size:.79rem;line-height:1.5;}
+.bot-hero-copy p{margin-top:8px;}
+.bot-hero-actions{position:absolute;right:26px;top:26px;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:48%;}
+.bot-live-status{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:7px 11px;border:1px solid var(--bot-line);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.72rem;font-weight:850;}
+.bot-live-status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;}
+.bot-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08);}
+.bot-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08);}
+.bot-overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-top:24px;}
+.bot-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid var(--bot-line);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px);}
+.bot-overview-card>div{display:flex;flex-direction:column;gap:4px;min-width:0;}.bot-overview-card small{color:var(--muted);font-size:.66rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;}.bot-overview-card strong{font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.bot-overview-icon{width:34px;height:34px;display:grid;place-items:center;flex:0 0 auto;border-radius:12px;color:#4de0ef;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.17);font-size:.78rem;font-weight:950;}.bot-overview-icon.bot-purple{color:#b19cff;background:rgba(139,92,246,.13);border-color:rgba(139,92,246,.2);}.bot-overview-icon.bot-green{color:#72e6a4;background:rgba(49,214,123,.11);border-color:rgba(49,214,123,.18);}.bot-overview-icon.bot-amber{color:#ffd36d;background:rgba(255,200,87,.11);border-color:rgba(255,200,87,.18);}
+
+.bot-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:22px;padding:6px;border:1px solid var(--bot-line);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px);}
+.bot-section-nav{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;}
+.bot-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease;}.bot-section-nav button span{margin-right:5px;color:#9b88ff;}.bot-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04);}.bot-section-nav button.active{color:#fff;border-color:rgba(139,92,246,.28);background:linear-gradient(135deg,rgba(139,92,246,.20),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04);}
+.bot-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(139,92,246,.28);border-radius:13px;background:#090d15;color:var(--text);font-weight:850;}
+.bot-section{display:none;animation:fadeIn .18s ease both;}.bot-section.active{display:block;}
+.bot-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px;}.bot-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em;}.bot-section-heading>.card-actions{justify-content:flex-end;}
+
+.bot-config-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-config-grid>.card,.bot-master-detail>.card{margin-top:0!important;}
+.bot-integration-card{min-height:255px;padding:20px;}.bot-card-heading{display:flex;align-items:center;gap:12px;margin-bottom:18px;}.bot-card-heading>div:nth-child(2){min-width:0;flex:1;}.bot-card-heading h4{font-size:1rem;margin-bottom:3px;}.bot-service-icon{width:44px;height:44px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;font-weight:950;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.05);}.bot-service-icon.telegram{color:#5ed8ff;background:rgba(41,182,246,.11);}.bot-service-icon.mercado{color:#77b8ff;background:rgba(52,131,250,.11);font-size:.72rem;}.bot-service-icon.trial{color:#ffd36d;background:rgba(255,200,87,.1);}.bot-service-icon.host{color:#9ff4bf;background:rgba(49,214,123,.1);}
+.bot-switch{position:relative;display:inline-flex;cursor:pointer;}.bot-switch input{position:absolute;opacity:0;pointer-events:none;}.bot-switch span{width:44px;height:24px;border-radius:999px;background:#222b38;border:1px solid rgba(148,163,184,.18);transition:.16s ease;}.bot-switch span::after{content:"";display:block;width:18px;height:18px;margin:2px;border-radius:50%;background:#8793a4;transition:.16s ease;}.bot-switch input:checked+span{background:rgba(49,214,123,.19);border-color:rgba(49,214,123,.38);}.bot-switch input:checked+span::after{transform:translateX(20px);background:#70e7a3;box-shadow:0 0 14px rgba(49,214,123,.45);}
+.bot-secret-state{display:inline-flex;margin-left:5px;color:var(--muted);font-size:.67rem;font-weight:750;}.bot-secret-state.is-set{color:#72e6a4;}.bot-secret-state.is-missing{color:#ffb3ba;}
+.bot-webhook-box{margin-top:13px;padding:13px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055);}.bot-webhook-box.hidden{display:none!important;}.bot-copy-row{display:flex;align-items:center;gap:8px;margin-top:10px;}.bot-copy-row code{min-width:0;flex:1;padding:9px 10px;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--bot-line);border-radius:11px;background:#06090f;color:#b8c4d4;font-size:.7rem;white-space:nowrap;}
+.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
+
+.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
+.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
+
+.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
+
+@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
+@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
+@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js
index 7700f3e..96c5063 100644
--- a/admin/assets/js/01-core.js
+++ b/admin/assets/js/01-core.js
@@ -1,6 +1,8 @@
// ─── State ───────────────────────────────────────────────────────────────────
-let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
+let sessionToken = sessionStorage.getItem("SESSION_TOKEN") || localStorage.getItem("SESSION_TOKEN") || "";
+if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
+localStorage.removeItem("SESSION_TOKEN");
let currentRole = "";
let currentUser = "";
let statsTimer = null, usersTimer = null, xrayTimer = null;
@@ -564,4 +566,3 @@ function patchRenderedInbounds(inbounds) {
}
return true;
}
-
diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js
index f974a98..27d40b2 100644
--- a/admin/assets/js/02-shell.js
+++ b/admin/assets/js/02-shell.js
@@ -56,7 +56,7 @@ loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
logoutBtn.addEventListener("click", async () => {
try { await api("/api/auth/logout", { method: "POST" }); } catch {}
sessionToken = "";
- localStorage.removeItem("SESSION_TOKEN");
+ sessionStorage.removeItem("SESSION_TOKEN");
clearTimers();
mainApp.classList.add("hidden");
loginOverlay.classList.remove("hidden");
@@ -83,7 +83,7 @@ async function doLogin() {
sessionToken = data.token;
currentRole = data.role;
currentUser = data.username;
- localStorage.setItem("SESSION_TOKEN", sessionToken);
+ sessionStorage.setItem("SESSION_TOKEN", sessionToken);
loginOverlay.classList.add("hidden");
mainApp.classList.remove("hidden");
initAfterLogin();
@@ -288,4 +288,3 @@ function refreshDashboard() {
if (currentRole === "superadmin") loadStats();
if (currentRole === "reseller") loadMe();
}
-
diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js
index 3d96204..d1310b5 100644
--- a/admin/assets/js/04-xray.js
+++ b/admin/assets/js/04-xray.js
@@ -161,7 +161,8 @@ function renderInbounds(inbounds, options = {}) {
}
inboundsContainer.innerHTML = "";
lastInboundsStructure = nextStructure;
- inbounds.forEach(ib => {
+ inbounds.forEach((ib, inboundIndex) => {
+ const formKey = String(inboundIndex);
const section = document.createElement("div");
section.dataset.inboundTag = String(ib.tag || "");
section.dataset.inboundProtocol = String(ib.protocol || "");
@@ -179,13 +180,18 @@ function renderInbounds(inbounds, options = {}) {
${escapeHTML(ib.tag || "untagged")}
:${escapeHTML(ib.port ?? "?")}
${t("{count} online", {count: onlineCount})}
-
- ${t("+ Add Client")} `;
+ `;
+ const openButton = document.createElement("button");
+ openButton.className = "btn btn-sm";
+ openButton.type = "button";
+ openButton.textContent = t("+ Add Client");
+ openButton.addEventListener("click", () => openAddClient(ib.tag, formKey));
+ hdr.appendChild(openButton);
section.appendChild(hdr);
// Add client mini-form (hidden by default)
const addForm = document.createElement("div");
- addForm.id = `add-form-${ib.tag}`;
+ addForm.id = `add-form-${formKey}`;
addForm.className = "hidden";
addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
addForm.innerHTML = `
@@ -193,19 +199,22 @@ function renderInbounds(inbounds, options = {}) {
- ${t("Email / label")}
- ${t("Display Name")}
- ${t("Expiry Date")}
- ${t("Max Connections")} ${t("(0 = unlimited)")}
+ ${t("Email / label")}
+ ${t("Display Name")}
+ ${t("Expiry Date")}
+ ${t("Max Connections")} ${t("(0 = unlimited)")}
- ${t("Add")}
- ${t("Cancel")}
+ ${t("Add")}
+ ${t("Cancel")}
`;
+ addForm.querySelector(`#genUUID-${formKey}`).addEventListener("click", () => { document.getElementById(`newUUID-${formKey}`).value = genUUID(); });
+ addForm.querySelector(`#addClient-${formKey}`).addEventListener("click", () => addClient(ib.tag, formKey));
+ addForm.querySelector(`#cancelAddClient-${formKey}`).addEventListener("click", () => addForm.classList.add("hidden"));
section.appendChild(addForm);
// Clients table
@@ -262,19 +271,19 @@ function renderInbounds(inbounds, options = {}) {
});
}
-function openAddClient(tag) {
- const form = document.getElementById(`add-form-${tag}`);
+function openAddClient(tag, formKey = tag) {
+ const form = document.getElementById(`add-form-${formKey}`);
if (form) { form.classList.remove("hidden"); }
- const uuidField = document.getElementById(`newUUID-${tag}`);
+ const uuidField = document.getElementById(`newUUID-${formKey}`);
if (uuidField && !uuidField.value) uuidField.value = genUUID();
}
-async function addClient(tag) {
- const uuidEl = document.getElementById(`newUUID-${tag}`);
- const emailEl = document.getElementById(`newEmail-${tag}`);
- const nameEl = document.getElementById(`newName-${tag}`);
- const expiryEl = document.getElementById(`newExpiry-${tag}`);
- const maxConnsEl = document.getElementById(`newMaxConns-${tag}`);
+async function addClient(tag, formKey = tag) {
+ const uuidEl = document.getElementById(`newUUID-${formKey}`);
+ const emailEl = document.getElementById(`newEmail-${formKey}`);
+ const nameEl = document.getElementById(`newName-${formKey}`);
+ const expiryEl = document.getElementById(`newExpiry-${formKey}`);
+ const maxConnsEl = document.getElementById(`newMaxConns-${formKey}`);
const uuid = (uuidEl?.value || "").trim();
const email = (emailEl?.value || "").trim();
const name = (nameEl?.value || "").trim();
@@ -349,4 +358,3 @@ async function loadXrayLogs() {
xLogsBox.scrollTop = xLogsBox.scrollHeight;
} catch (e) { if (e.message==="auth") doAuthError(); }
}
-
diff --git a/admin/assets/js/05-resellers.js b/admin/assets/js/05-resellers.js
index 3e87b78..24cc266 100644
--- a/admin/assets/js/05-resellers.js
+++ b/admin/assets/js/05-resellers.js
@@ -38,13 +38,13 @@ function renderResellers(list) {
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
const tr = document.createElement("tr");
tr.innerHTML = `
- ${r.username}
+ ${escapeHTML(r.username)}
${used} / ${max || "∞"}
Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}
- ${r.expires_at ? fmtDate(r.expires_at) : "—"}
+ ${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}
${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}
`;
const tdA = tr.lastElementChild;
@@ -110,4 +110,3 @@ async function deleteReseller(username) {
else resellerStatus.textContent = "Error deleting.";
}
}
-
diff --git a/admin/assets/js/07-stats-logs.js b/admin/assets/js/07-stats-logs.js
index 34102a8..534690d 100644
--- a/admin/assets/js/07-stats-logs.js
+++ b/admin/assets/js/07-stats-logs.js
@@ -91,7 +91,7 @@ async function loadDnsttHealth() {
const a = rows[i];
const b = rows[i + 1] || ["", ""];
const tr = document.createElement("tr");
- tr.innerHTML = `${a[0]} ${fmtInt(a[1])} ${b[0]} ${b[0] ? fmtInt(b[1]) : ""} `;
+ tr.innerHTML = `${escapeHTML(a[0])} ${escapeHTML(fmtInt(a[1]))} ${escapeHTML(b[0])} ${b[0] ? escapeHTML(fmtInt(b[1])) : ""} `;
dnsttHealthBody.appendChild(tr);
}
}
@@ -135,7 +135,7 @@ async function loadStats() {
totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0);
if (!ifaceBody) return;
const tr = document.createElement("tr");
- tr.innerHTML = `${it.name} ${fmtMbps(it.rx_mbps)} ${fmtMbps(it.tx_mbps)} ${fmtBytes(it.rx_bytes)} ${fmtBytes(it.tx_bytes)} `;
+ tr.innerHTML = `${escapeHTML(it.name)} ${escapeHTML(fmtMbps(it.rx_mbps))} ${escapeHTML(fmtMbps(it.tx_mbps))} ${escapeHTML(fmtBytes(it.rx_bytes))} ${escapeHTML(fmtBytes(it.tx_bytes))} `;
ifaceBody.appendChild(tr);
});
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
@@ -175,13 +175,13 @@ function renderVnstatRows(body, rows, emptyLabel) {
body.innerHTML = "";
if (!rows.length) {
const tr = document.createElement("tr");
- tr.innerHTML = `${emptyLabel} `;
+ tr.innerHTML = `${escapeHTML(emptyLabel)} `;
body.appendChild(tr);
return;
}
rows.forEach(r => {
const tr = document.createElement("tr");
- tr.innerHTML = `${r.period || "--"} ${r.iface || "--"} ${fmtBytes(r.rx_bytes||0)} ${fmtBytes(r.tx_bytes||0)} ${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))} `;
+ tr.innerHTML = `${escapeHTML(r.period || "--")} ${escapeHTML(r.iface || "--")} ${escapeHTML(fmtBytes(r.rx_bytes||0))} ${escapeHTML(fmtBytes(r.tx_bytes||0))} ${escapeHTML(fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0))))} `;
body.appendChild(tr);
});
}
@@ -273,4 +273,3 @@ async function clearPanelLog() {
else st.textContent = "Error cleaning panel log: " + e.message;
}
}
-
diff --git a/admin/assets/js/08-server-config.js b/admin/assets/js/08-server-config.js
index 9b4d354..7108f3c 100644
--- a/admin/assets/js/08-server-config.js
+++ b/admin/assets/js/08-server-config.js
@@ -263,8 +263,8 @@ function renderTLSForwarders() {
tlsForwardersState.forEach((fw, i) => {
const row = document.createElement("div");
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
- row.innerHTML = `${fw.listen}
- ${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"} `;
+ row.innerHTML = `${escapeHTML(fw.listen || "")}
+ ${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")} `;
const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger btn-sm";
delBtn.textContent = "Remove";
@@ -446,4 +446,3 @@ async function loadDnsttPubkey() {
else st.textContent = "Error: " + e.message;
}
}
-
diff --git a/admin/assets/js/09-xray-wizard.js b/admin/assets/js/09-xray-wizard.js
index 8c67976..99931b4 100644
--- a/admin/assets/js/09-xray-wizard.js
+++ b/admin/assets/js/09-xray-wizard.js
@@ -113,9 +113,9 @@ function renderWzInbounds() {
const sec = ss.security || "";
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
- row.innerHTML = `${ib.protocol}
- ${ib.tag||"untagged"}${portStr}
- ${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel} `;
+ row.innerHTML = `${escapeHTML(ib.protocol || "")}
+ ${escapeHTML((ib.tag||"untagged")+portStr)}
+ ${escapeHTML((ib.listen||"0.0.0.0")+(net?" · "+net:"")+modeLabel+secLabel)} `;
const clients = ib.settings?.clients;
if (Array.isArray(clients) && clients.length) {
const badge = document.createElement("span");
@@ -392,4 +392,3 @@ async function applyWizardConfig() {
else if (st) st.textContent = "Error: " + e.message;
}
}
-
diff --git a/admin/assets/js/10-boot.js b/admin/assets/js/10-boot.js
index 7e88f43..eb64c1f 100644
--- a/admin/assets/js/10-boot.js
+++ b/admin/assets/js/10-boot.js
@@ -1,7 +1,7 @@
// ─── Auth error ───────────────────────────────────────────────────────────────
function doAuthError() {
sessionToken = "";
- localStorage.removeItem("SESSION_TOKEN");
+ sessionStorage.removeItem("SESSION_TOKEN");
clearTimers();
mainApp.classList.add("hidden");
loginOverlay.classList.remove("hidden");
diff --git a/admin/assets/js/12-bot.js b/admin/assets/js/12-bot.js
index e5c32b3..1ef6c4d 100644
--- a/admin/assets/js/12-bot.js
+++ b/admin/assets/js/12-bot.js
@@ -1,340 +1,615 @@
-// ─── Bot / Vendas (Telegram + Mercado Pago) ─────────────────────────────────
-// Superadmin-only tab. Uses the shared api() helper from 01-core.js.
+// Bot / Vendas — safe DOM rendering and sectioned management workspace.
-function botEsc(s) {
- return String(s == null ? "" : s)
- .replace(/&/g, "&").replace(//g, ">")
- .replace(/"/g, """).replace(/'/g, "'");
-}
-function botStatus(id, msg, ok) {
- const el = document.getElementById(id);
- if (el) { el.textContent = msg; el.style.color = ok === false ? "var(--danger,#e5484d)" : ""; }
-}
-function brl(cents) {
- return "R$ " + (Number(cents || 0) / 100).toFixed(2).replace(".", ",");
-}
+const botState = {
+ config: null,
+ plans: [],
+ packages: [],
+ users: [],
+ transactions: [],
+ section: sessionStorage.getItem("BOT_SECTION") || "config",
+};
-async function loadBotTab() {
- loadBotInbounds();
- const sel = document.getElementById("botSection");
- botShowSection(sel ? sel.value : "config");
-}
-
-// Show one section at a time and lazy-load its data.
-function botShowSection(name) {
- document.querySelectorAll("#tab-bot .bot-section").forEach(s => { s.style.display = "none"; });
- const el = document.getElementById("botSec-" + name);
- if (el) el.style.display = "";
- switch (name) {
- case "config": loadBotConfig(); break;
- case "plans": loadBotPlans(); break;
- case "packages": loadBotPkgs(); break;
- case "messages": loadBotSettings(); break;
- case "users": loadBotUsers(); break;
- case "transactions": loadBotTxns(); break;
+function botStatus(id, message, ok) {
+ const element = document.getElementById(id);
+ if (!element) return;
+ element.textContent = message;
+ if (id === "botConfigStatus") {
+ element.classList.toggle("is-ok", ok === true);
+ element.classList.toggle("is-error", ok === false);
+ } else {
+ element.style.color = ok === false ? "var(--danger)" : "";
}
}
-// ─── Config ───
+function botBRL(cents) {
+ return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(cents || 0) / 100);
+}
+
+function botNode(tag, options = {}, children = []) {
+ const element = document.createElement(tag);
+ if (options.className) element.className = options.className;
+ if (options.text != null) element.textContent = String(options.text);
+ if (options.title) element.title = options.title;
+ if (options.type) element.type = options.type;
+ for (const child of children) if (child) element.appendChild(child);
+ return element;
+}
+
+function botCell(content, className = "") {
+ const cell = document.createElement("td");
+ if (className) cell.className = className;
+ if (content instanceof Node) cell.appendChild(content);
+ else cell.textContent = String(content == null || content === "" ? "—" : content);
+ return cell;
+}
+
+function botPrimaryCell(title, detail) {
+ const wrapper = botNode("div", { className: "bot-primary-cell" });
+ wrapper.appendChild(botNode("strong", { text: title || "—" }));
+ if (detail) wrapper.appendChild(botNode("small", { text: detail }));
+ return wrapper;
+}
+
+function botBadge(label, tone) {
+ return botNode("span", { className: "bot-status " + tone, text: label });
+}
+
+function botButton(label, handler, className = "btn btn-ghost btn-sm") {
+ const button = botNode("button", { className, text: label, type: "button" });
+ button.addEventListener("click", handler);
+ return button;
+}
+
+function botActions(buttons) {
+ return botNode("div", { className: "bot-row-actions" }, buttons);
+}
+
+function botEmptyRow(body, columns, message) {
+ const row = botNode("tr", { className: "bot-empty-row" });
+ const cell = botCell(message);
+ cell.colSpan = columns;
+ row.appendChild(cell);
+ body.replaceChildren(row);
+}
+
+async function botRequest(path, options = {}) {
+ const response = await api(path, options);
+ if (!response.ok) {
+ const message = (await response.text()).trim();
+ throw new Error(message || `HTTP ${response.status}`);
+ }
+ if (response.status === 204) return null;
+ return response.json();
+}
+
+function botHandleError(error, statusID, fallback) {
+ if (error.message === "auth") {
+ doAuthError();
+ return;
+ }
+ botStatus(statusID, error.message || fallback, false);
+}
+
+function botSetSection(section) {
+ const allowedSections = new Set(["config", "plans", "packages", "messages", "users", "transactions"]);
+ if (!allowedSections.has(section)) section = "config";
+ botState.section = section;
+ sessionStorage.setItem("BOT_SECTION", section);
+ document.querySelectorAll("[data-bot-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.botPanel === section));
+ document.querySelectorAll("[data-bot-section]").forEach(button => button.classList.toggle("active", button.dataset.botSection === section));
+ const select = document.getElementById("botSection");
+ if (select) select.value = section;
+}
+
+function botUpdateMetrics() {
+ const config = botState.config;
+ const stateMetric = document.getElementById("botMetricState");
+ if (stateMetric) stateMetric.textContent = config ? (config.enabled ? "Ativo" : "Pausado") : "Indisponível";
+ const plansMetric = document.getElementById("botMetricPlans");
+ if (plansMetric) plansMetric.textContent = String(botState.plans.filter(plan => plan.IsActive).length);
+ const usersMetric = document.getElementById("botMetricUsers");
+ if (usersMetric) usersMetric.textContent = String(botState.users.length);
+ const pendingMetric = document.getElementById("botMetricPending");
+ if (pendingMetric) pendingMetric.textContent = String(botState.transactions.filter(transaction => transaction.Status === "pending").length);
+}
+
+async function loadBotTab() {
+ botSetSection(botState.section);
+ botStatus("botConfigStatus", "Atualizando dados…");
+ await Promise.allSettled([
+ loadBotConfig(), loadBotInbounds(), loadBotPlans(), loadBotPkgs(),
+ loadBotUsers(), loadBotTxns(), loadBotSettings(),
+ ]);
+ botUpdateMetrics();
+}
+
+// Configuration
async function loadBotConfig() {
try {
- const res = await api("/api/bot/config");
- const c = await res.json();
- const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; };
- const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; };
- chk("botEnabled", c.enabled);
- set("botMPConfirmMode", c.mp_confirm_mode);
- set("botMPPollInterval", c.mp_poll_interval);
- set("botPixExp", c.pix_expiration_minutes);
- chk("botTrialEnabled", c.trial_enabled);
- set("botTrialHours", c.trial_hours);
- set("botTrialMaxConns", c.trial_max_connections);
- set("botTrialKind", c.trial_kind);
- set("botTrialInbound", c.trial_inbound_tag);
- set("botAdminIDs", (c.admin_telegram_ids || []).join(","));
- set("botPublicHost", c.public_host);
- set("botXrayPublicHost", c.xray_public_host);
- const hint = (id, ok) => { const e = document.getElementById(id); if (e) e.textContent = ok ? "✓ configurado" : "não definido"; };
- hint("botHasTgToken", c.has_telegram_token);
- hint("botHasMpToken", c.has_mp_access_token);
- hint("botHasMpSecret", c.has_mp_webhook_secret);
+ const config = await botRequest("/api/bot/config");
+ botState.config = config;
+ const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
+ const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; };
+ setChecked("botEnabled", config.enabled);
+ setValue("botMPConfirmMode", config.mp_confirm_mode);
+ setValue("botMPPollInterval", config.mp_poll_interval);
+ setValue("botPixExp", config.pix_expiration_minutes);
+ setChecked("botTrialEnabled", config.trial_enabled);
+ setValue("botTrialHours", config.trial_hours);
+ setValue("botTrialMaxConns", config.trial_max_connections);
+ setValue("botTrialKind", config.trial_kind);
+ setValue("botTrialInbound", config.trial_inbound_tag);
+ setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", "));
+ setValue("botPublicHost", config.public_host);
+ setValue("botXrayPublicHost", config.xray_public_host);
+ botSetSecretState("botHasTgToken", config.has_telegram_token);
+ botSetSecretState("botHasMpToken", config.has_mp_access_token);
+ botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret);
botToggleMPWebhookBox();
- botStatus("botConfigStatus", "Carregado.");
- } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); }
+ botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true);
+ botUpdateMetrics();
+ return config;
+ } catch (error) {
+ botState.config = null;
+ botHandleError(error, "botConfigStatus", "Erro ao carregar configuração.");
+ botUpdateMetrics();
+ }
+}
+
+function botSetSecretState(id, configured) {
+ const element = document.getElementById(id);
+ if (!element) return;
+ element.textContent = configured ? "● protegido" : "○ não configurado";
+ element.classList.toggle("is-set", !!configured);
+ element.classList.toggle("is-missing", !configured);
}
function botToggleMPWebhookBox() {
const mode = document.getElementById("botMPConfirmMode")?.value;
- const box = document.getElementById("botMPWebhookBox");
- if (box) box.style.display = mode === "webhook" ? "" : "none";
+ document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook");
const url = document.getElementById("botMPWebhookURL");
if (url) url.textContent = location.origin + "/api/mp/webhook";
}
async function saveBotConfig() {
- const val = id => (document.getElementById(id)?.value || "").trim();
- const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0;
- const chk = id => !!document.getElementById(id)?.checked;
- const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
- const body = {
- enabled: chk("botEnabled"),
- telegram_token: val("botTelegramToken"),
- mp_access_token: val("botMPToken"),
- mp_confirm_mode: val("botMPConfirmMode"),
- mp_webhook_secret: val("botMPWebhookSecret"),
- mp_poll_interval: val("botMPPollInterval"),
- pix_expiration_minutes: num("botPixExp"),
- trial_enabled: chk("botTrialEnabled"),
- trial_hours: num("botTrialHours"),
- trial_max_connections: num("botTrialMaxConns"),
- trial_kind: val("botTrialKind"),
- trial_inbound_tag: val("botTrialInbound"),
- admin_telegram_ids: ids,
- public_host: val("botPublicHost"),
- xray_public_host: val("botXrayPublicHost"),
+ const value = id => (document.getElementById(id)?.value || "").trim();
+ const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0;
+ const checked = id => !!document.getElementById(id)?.checked;
+ const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger);
+ const payload = {
+ enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"),
+ mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"),
+ mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"),
+ pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"),
+ trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"),
+ trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"),
+ admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"),
};
+ botStatus("botConfigStatus", "Salvando e reiniciando…");
try {
- await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) });
- ["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; });
- botStatus("botConfigStatus", "Configuração salva e bot reiniciado.");
- loadBotConfig();
- } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); }
+ await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) });
+ ["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; });
+ await loadBotConfig();
+ botStatus("botConfigStatus", "Configuração salva", true);
+ } catch (error) {
+ botHandleError(error, "botConfigStatus", "Erro ao salvar configuração.");
+ }
}
async function testBot() {
- botStatus("botConfigStatus", "Testando...");
- const body = {
+ botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…");
+ const payload = {
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
};
try {
- const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) });
- const r = await res.json();
- const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`;
- const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`;
- botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok);
- } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); }
+ const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) });
+ const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`;
+ const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`;
+ botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok);
+ } catch (error) {
+ botHandleError(error, "botConfigStatus", "Erro ao testar integrações.");
+ }
+}
+
+async function botCopyWebhook() {
+ const value = document.getElementById("botMPWebhookURL")?.textContent || "";
+ try {
+ await navigator.clipboard.writeText(value);
+ const button = document.getElementById("botCopyWebhookBtn");
+ if (button) {
+ button.textContent = "Copiado";
+ setTimeout(() => { button.textContent = "Copiar"; }, 1400);
+ }
+ } catch {
+ botStatus("botConfigStatus", "Não foi possível copiar a URL.", false);
+ }
}
-// ─── Inbounds datalist ───
async function loadBotInbounds() {
try {
- const res = await api("/api/xray/inbounds");
- const list = await res.json();
- const dl = document.getElementById("botInboundList");
- if (dl) dl.innerHTML = (list || []).map(ib => `${botEsc(ib.protocol)} `).join("");
- } catch (e) { /* xray may be off; ignore */ }
+ const inbounds = await botRequest("/api/xray/inbounds");
+ const datalist = document.getElementById("botInboundList");
+ if (!datalist) return;
+ datalist.replaceChildren(...(inbounds || []).map(inbound => {
+ const option = document.createElement("option");
+ option.value = String(inbound.tag || "");
+ option.textContent = String(inbound.protocol || "");
+ return option;
+ }));
+ } catch (error) {
+ if (error.message === "auth") doAuthError();
+ }
}
-// ─── Plans ───
+// Plans
async function loadBotPlans() {
try {
- const res = await api("/api/bot/plans");
- const plans = await res.json() || [];
- document.getElementById("botPlanCount").textContent = plans.length;
- document.getElementById("botPlansBody").innerHTML = plans.map(p => `
-
- ${botEsc(p.Name)} ${botEsc(p.Kind)} ${p.Days}
- ${brl(p.PriceCents)} ${p.CreditCost}
- ${p.IsActive ? "✅" : "—"}
-
- Editar
- Excluir
-
- `).join("");
- botStatus("botPlansStatus", "Ready.");
- } catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao carregar.", false); }
-}
-function botEditPlan(p) {
- const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; };
- set("planId", p.ID); set("planName", p.Name); set("planKind", p.Kind); set("planDays", p.Days);
- set("planMaxConns", p.MaxConnections); set("planUpMbps", p.LimitMbpsUp); set("planDownMbps", p.LimitMbpsDown);
- set("planInbound", p.XrayInboundTag); set("planProtocol", p.XrayProtocol);
- set("planPrice", (p.PriceCents / 100).toFixed(2)); set("planCreditCost", p.CreditCost);
- set("planServerId", p.ServerID); set("planSort", p.SortOrder);
- document.getElementById("planActive").checked = !!p.IsActive;
- document.getElementById("botPlanFormTitle").textContent = "Editar Plano #" + p.ID;
-}
-function botClearPlanForm() {
- document.getElementById("botPlanForm").reset();
- document.getElementById("planId").value = "";
- document.getElementById("botPlanFormTitle").textContent = "Novo Plano";
-}
-async function botSavePlan(ev) {
- ev.preventDefault();
- const val = id => document.getElementById(id).value;
- const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0;
- const body = {
- id: num("planId"), name: val("planName"), kind: val("planKind"), days: num("planDays"),
- max_connections: num("planMaxConns"), limit_mbps_up: num("planUpMbps"), limit_mbps_down: num("planDownMbps"),
- xray_inbound_tag: val("planInbound"), xray_protocol: val("planProtocol"),
- price_cents: Math.round(parseFloat(val("planPrice") || "0") * 100),
- credit_cost: num("planCreditCost"), server_id: val("planServerId"), sort_order: num("planSort"),
- is_active: document.getElementById("planActive").checked,
- };
- // map to Go struct JSON tags (exported field names)
- const payload = {
- ID: body.id, Name: body.name, Kind: body.kind, Days: body.days, MaxConnections: body.max_connections,
- LimitMbpsUp: body.limit_mbps_up, LimitMbpsDown: body.limit_mbps_down, XrayInboundTag: body.xray_inbound_tag,
- XrayProtocol: body.xray_protocol, PriceCents: body.price_cents, CreditCost: body.credit_cost,
- ServerID: body.server_id, IsActive: body.is_active, SortOrder: body.sort_order,
- };
- try {
- await api("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
- botClearPlanForm(); loadBotPlans(); botStatus("botPlansStatus", "Plano salvo.");
- } catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao salvar.", false); }
-}
-async function botDeletePlan(id) {
- if (!confirm("Excluir este plano?")) return;
- try { await api("/api/bot/plans?id=" + id, { method: "DELETE" }); loadBotPlans(); }
- catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao excluir.", false); }
+ const plans = await botRequest("/api/bot/plans");
+ botState.plans = plans || [];
+ renderBotPlans(botState.plans);
+ botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true);
+ botUpdateMetrics();
+ return plans;
+ } catch (error) {
+ botHandleError(error, "botPlansStatus", "Erro ao carregar planos.");
+ }
}
-// ─── Credit packages ───
+function renderBotPlans(plans) {
+ const body = document.getElementById("botPlansBody");
+ if (!body) return;
+ document.getElementById("botPlanCount").textContent = String(plans.length);
+ if (!plans.length) return botEmptyRow(body, 6, "Nenhum plano cadastrado. Crie o primeiro ao lado.");
+ const rows = plans.map(plan => {
+ const row = document.createElement("tr");
+ const delivery = plan.Kind === "xray" ? `Xray${plan.XrayProtocol ? " · " + plan.XrayProtocol.toUpperCase() : ""}` : "SSH";
+ const price = botPrimaryCell(botBRL(plan.PriceCents), `${plan.CreditCost || 0} crédito(s)`);
+ row.append(
+ botCell(botPrimaryCell(plan.Name, `#${plan.ID}`)), botCell(delivery),
+ botCell(`${plan.Days} dias`), botCell(price),
+ botCell(botBadge(plan.IsActive ? "Ativo" : "Oculto", plan.IsActive ? "active" : "inactive")),
+ botCell(botActions([
+ botButton("Editar", () => botEditPlan(plan)),
+ botButton("Excluir", () => botDeletePlan(plan.ID), "btn btn-danger btn-sm"),
+ ])),
+ );
+ return row;
+ });
+ body.replaceChildren(...rows);
+}
+
+function botEditPlan(plan) {
+ const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
+ set("planId", plan.ID); set("planName", plan.Name); set("planKind", plan.Kind); set("planDays", plan.Days);
+ set("planMaxConns", plan.MaxConnections); set("planUpMbps", plan.LimitMbpsUp); set("planDownMbps", plan.LimitMbpsDown);
+ set("planInbound", plan.XrayInboundTag); set("planProtocol", plan.XrayProtocol); set("planPrice", (Number(plan.PriceCents) / 100).toFixed(2));
+ set("planCreditCost", plan.CreditCost); set("planServerId", plan.ServerID); set("planSort", plan.SortOrder);
+ document.getElementById("planActive").checked = !!plan.IsActive;
+ document.getElementById("botPlanFormTitle").textContent = `Editar ${plan.Name}`;
+ document.getElementById("planName")?.focus();
+}
+
+function botClearPlanForm() {
+ document.getElementById("botPlanForm")?.reset();
+ document.getElementById("planId").value = "";
+ document.getElementById("planActive").checked = true;
+ document.getElementById("botPlanFormTitle").textContent = "Novo plano";
+}
+
+async function botSavePlan(event) {
+ event.preventDefault();
+ const value = id => document.getElementById(id).value.trim();
+ const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
+ const payload = {
+ ID: number("planId"), Name: value("planName"), Kind: value("planKind"), Days: number("planDays"),
+ MaxConnections: number("planMaxConns"), LimitMbpsUp: number("planUpMbps"), LimitMbpsDown: number("planDownMbps"),
+ XrayInboundTag: value("planInbound"), XrayProtocol: value("planProtocol"),
+ PriceCents: Math.round((Number.parseFloat(value("planPrice")) || 0) * 100), CreditCost: number("planCreditCost"),
+ ServerID: value("planServerId"), IsActive: document.getElementById("planActive").checked, SortOrder: number("planSort"),
+ };
+ botStatus("botPlansStatus", "Salvando plano…");
+ try {
+ await botRequest("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
+ botClearPlanForm();
+ await loadBotPlans();
+ botStatus("botPlansStatus", "Plano salvo.", true);
+ } catch (error) {
+ botHandleError(error, "botPlansStatus", "Erro ao salvar plano.");
+ }
+}
+
+async function botDeletePlan(id) {
+ if (!confirm("Excluir este plano? Esta ação não pode ser desfeita.")) return;
+ try {
+ await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
+ await loadBotPlans();
+ } catch (error) {
+ botHandleError(error, "botPlansStatus", "Erro ao excluir plano.");
+ }
+}
+
+// Credit packages
async function loadBotPkgs() {
try {
- const res = await api("/api/bot/credit-packages");
- const pkgs = await res.json() || [];
- document.getElementById("botPkgCount").textContent = pkgs.length;
- document.getElementById("botPkgsBody").innerHTML = pkgs.map(p => `
-
- ${botEsc(p.Name)} ${p.Credits} ${brl(p.PriceCents)}
- ${p.IsActive ? "✅" : "—"}
-
- Editar
- Excluir
-
- `).join("");
- botStatus("botPkgStatus", "Ready.");
- } catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao carregar.", false); }
-}
-function botEditPkg(p) {
- const set = (id, v) => { document.getElementById(id).value = v ?? ""; };
- set("pkgId", p.ID); set("pkgName", p.Name); set("pkgCredits", p.Credits);
- set("pkgPrice", (p.PriceCents / 100).toFixed(2)); set("pkgSort", p.SortOrder);
- document.getElementById("pkgActive").checked = !!p.IsActive;
-}
-function botClearPkgForm() { document.getElementById("botPkgForm").reset(); document.getElementById("pkgId").value = ""; }
-async function botSavePkg(ev) {
- ev.preventDefault();
- const val = id => document.getElementById(id).value;
- const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0;
- const payload = {
- ID: num("pkgId"), Name: val("pkgName"), Credits: num("pkgCredits"),
- PriceCents: Math.round(parseFloat(val("pkgPrice") || "0") * 100),
- SortOrder: num("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
- };
- try { await api("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) }); botClearPkgForm(); loadBotPkgs(); botStatus("botPkgStatus", "Pacote salvo."); }
- catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao salvar.", false); }
-}
-async function botDeletePkg(id) {
- if (!confirm("Excluir este pacote?")) return;
- try { await api("/api/bot/credit-packages?id=" + id, { method: "DELETE" }); loadBotPkgs(); }
- catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao excluir.", false); }
+ const packages = await botRequest("/api/bot/credit-packages");
+ botState.packages = packages || [];
+ renderBotPackages(botState.packages);
+ botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true);
+ return packages;
+ } catch (error) {
+ botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes.");
+ }
}
-// ─── Bot users ───
+function renderBotPackages(packages) {
+ const body = document.getElementById("botPkgsBody");
+ if (!body) return;
+ document.getElementById("botPkgCount").textContent = String(packages.length);
+ if (!packages.length) return botEmptyRow(body, 5, "Nenhum pacote de créditos cadastrado.");
+ body.replaceChildren(...packages.map(item => {
+ const row = document.createElement("tr");
+ row.append(
+ botCell(botPrimaryCell(item.Name, `#${item.ID}`)), botCell(`${item.Credits} créditos`), botCell(botBRL(item.PriceCents)),
+ botCell(botBadge(item.IsActive ? "Ativo" : "Oculto", item.IsActive ? "active" : "inactive")),
+ botCell(botActions([
+ botButton("Editar", () => botEditPkg(item)),
+ botButton("Excluir", () => botDeletePkg(item.ID), "btn btn-danger btn-sm"),
+ ])),
+ );
+ return row;
+ }));
+}
+
+function botEditPkg(item) {
+ const set = (id, value) => { document.getElementById(id).value = value ?? ""; };
+ set("pkgId", item.ID); set("pkgName", item.Name); set("pkgCredits", item.Credits);
+ set("pkgPrice", (Number(item.PriceCents) / 100).toFixed(2)); set("pkgSort", item.SortOrder);
+ document.getElementById("pkgActive").checked = !!item.IsActive;
+ document.getElementById("pkgName")?.focus();
+}
+
+function botClearPkgForm() {
+ document.getElementById("botPkgForm")?.reset();
+ document.getElementById("pkgId").value = "";
+ document.getElementById("pkgActive").checked = true;
+}
+
+async function botSavePkg(event) {
+ event.preventDefault();
+ const value = id => document.getElementById(id).value.trim();
+ const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
+ const payload = {
+ ID: number("pkgId"), Name: value("pkgName"), Credits: number("pkgCredits"),
+ PriceCents: Math.round((Number.parseFloat(value("pkgPrice")) || 0) * 100),
+ SortOrder: number("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
+ };
+ botStatus("botPkgStatus", "Salvando pacote…");
+ try {
+ await botRequest("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) });
+ botClearPkgForm();
+ await loadBotPkgs();
+ botStatus("botPkgStatus", "Pacote salvo.", true);
+ } catch (error) {
+ botHandleError(error, "botPkgStatus", "Erro ao salvar pacote.");
+ }
+}
+
+async function botDeletePkg(id) {
+ if (!confirm("Excluir este pacote de créditos?")) return;
+ try {
+ await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
+ await loadBotPkgs();
+ } catch (error) {
+ botHandleError(error, "botPkgStatus", "Erro ao excluir pacote.");
+ }
+}
+
+// Users
async function loadBotUsers() {
try {
- const res = await api("/api/bot/users");
- const users = await res.json() || [];
- document.getElementById("botUserCount").textContent = users.length;
- document.getElementById("botUsersBody").innerHTML = users.map(u => `
-
- ${u.TelegramID} ${botEsc(u.FirstName)} ${botEsc(u.Username)}
- ${botEsc(u.Role)} ${botEsc(u.LinkedAdminUsername)} ${u.CreditBalance}
-
- Papel
- Créditos
- ${u.Role === "blocked" ? "Desbloq." : "Bloquear"}
-
- `).join("");
- botStatus("botUsersStatus", "Ready.");
- } catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro ao carregar.", false); }
-}
-async function botPromote(tid) {
- const role = prompt("Papel (customer / reseller / blocked):", "reseller");
- if (!role) return;
- let linked = "";
- if (role === "reseller") { linked = prompt("Username do revendedor (admin_users) vinculado:", "") || ""; }
- try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "set_role", role, linked_admin_username: linked }) }); loadBotUsers(); }
- catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); }
-}
-async function botCredit(tid) {
- const v = prompt("Ajuste de créditos (use negativo para remover):", "10");
- if (v === null) return;
- const n = parseInt(v, 10); if (isNaN(n)) return;
- try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "adjust_credits", credits: n }) }); loadBotUsers(); }
- catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro (saldo insuficiente?).", false); }
-}
-async function botBlock(tid, isBlocked) {
- try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: isBlocked ? "unblock" : "block" }) }); loadBotUsers(); }
- catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); }
+ const users = await botRequest("/api/bot/users");
+ botState.users = users || [];
+ renderBotUsers(botState.users);
+ botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true);
+ botUpdateMetrics();
+ return users;
+ } catch (error) {
+ botHandleError(error, "botUsersStatus", "Erro ao carregar clientes.");
+ }
}
-// ─── Transactions ───
+function renderBotUsers(users) {
+ const body = document.getElementById("botUsersBody");
+ if (!body) return;
+ document.getElementById("botUserCount").textContent = String(users.length);
+ if (!users.length) return botEmptyRow(body, 6, "Nenhum cliente conversou com o bot ainda.");
+ body.replaceChildren(...users.map(user => {
+ const row = document.createElement("tr");
+ const displayName = user.FirstName || user.Username || "Sem nome";
+ const username = user.Username ? `@${user.Username}` : "Sem username";
+ const isBlocked = user.Role === "blocked";
+ row.append(
+ botCell(botPrimaryCell(displayName, username)), botCell(user.TelegramID),
+ botCell(botBadge(user.Role || "customer", user.Role || "customer")),
+ botCell(user.LinkedAdminUsername || "—"), botCell(`${user.CreditBalance || 0} créditos`),
+ botCell(botActions([
+ botButton("Função", () => botOpenUserAction(user, "role")),
+ botButton("Saldo", () => botOpenUserAction(user, "credits")),
+ botButton(isBlocked ? "Desbloquear" : "Bloquear", () => botToggleBlock(user), isBlocked ? "btn btn-ghost btn-sm" : "btn btn-danger btn-sm"),
+ ])),
+ );
+ return row;
+ }));
+}
+
+function botOpenUserAction(user, mode) {
+ document.getElementById("botActionTelegramID").value = String(user.TelegramID);
+ document.getElementById("botActionMode").value = mode;
+ document.getElementById("botUserActionTitle").textContent = mode === "role" ? "Alterar função" : "Ajustar créditos";
+ document.getElementById("botUserActionSubtitle").textContent = `${user.FirstName || user.Username || "Cliente"} · ID ${user.TelegramID}`;
+ document.getElementById("botRoleFields").classList.toggle("hidden", mode !== "role");
+ document.getElementById("botCreditFields").classList.toggle("hidden", mode !== "credits");
+ document.getElementById("botActionRole").value = user.Role || "customer";
+ document.getElementById("botActionLinked").value = user.LinkedAdminUsername || "";
+ document.getElementById("botActionCredits").value = "";
+ botToggleLinkedAdminField();
+ document.getElementById("botUserActionModal").classList.remove("hidden");
+ document.body.classList.add("bot-modal-open");
+ setTimeout(() => (mode === "role" ? document.getElementById("botActionRole") : document.getElementById("botActionCredits"))?.focus(), 0);
+}
+
+function botCloseUserAction() {
+ document.getElementById("botUserActionModal")?.classList.add("hidden");
+ document.body.classList.remove("bot-modal-open");
+}
+
+function botToggleLinkedAdminField() {
+ const show = document.getElementById("botActionRole")?.value === "reseller";
+ document.getElementById("botActionLinkedField")?.classList.toggle("hidden", !show);
+}
+
+async function botSaveUserAction(event) {
+ event.preventDefault();
+ const telegramID = Number.parseInt(document.getElementById("botActionTelegramID").value, 10);
+ const mode = document.getElementById("botActionMode").value;
+ const payload = mode === "role" ? {
+ telegram_id: telegramID, action: "set_role", role: document.getElementById("botActionRole").value,
+ linked_admin_username: document.getElementById("botActionLinked").value.trim(),
+ } : {
+ telegram_id: telegramID, action: "adjust_credits", credits: Number.parseInt(document.getElementById("botActionCredits").value, 10) || 0,
+ };
+ try {
+ await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify(payload) });
+ botCloseUserAction();
+ await loadBotUsers();
+ botStatus("botUsersStatus", "Cliente atualizado.", true);
+ } catch (error) {
+ botHandleError(error, "botUsersStatus", "Erro ao atualizar cliente.");
+ }
+}
+
+async function botToggleBlock(user) {
+ const isBlocked = user.Role === "blocked";
+ if (!confirm(isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?")) return;
+ try {
+ await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
+ await loadBotUsers();
+ } catch (error) {
+ botHandleError(error, "botUsersStatus", "Erro ao alterar bloqueio.");
+ }
+}
+
+// Transactions
async function loadBotTxns() {
const filter = document.getElementById("botTxnFilter")?.value || "";
try {
- const res = await api("/api/bot/transactions?limit=200&status=" + encodeURIComponent(filter));
- const txns = await res.json() || [];
- document.getElementById("botTxnCount").textContent = txns.length;
- document.getElementById("botTxnsBody").innerHTML = txns.map(t => `
-
- ${t.ID} ${t.TelegramID} ${botEsc(t.Type)} ${brl(t.AmountCents)}
- ${botEsc(t.Status)} ${botEsc(t.TargetUsername)}
- ${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))}
-
- ${t.Status === "pending" || t.Status === "approved" ? `Reprocessar ` : ""}
- ${t.Status !== "refunded" ? `Estornar ` : ""}
-
- `).join("");
- botStatus("botTxnStatus", "Ready.");
- } catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro ao carregar.", false); }
-}
-async function botReprocess(id) {
- try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) }); botStatus("botTxnStatus", "Reprocessando #" + id + "..."); setTimeout(loadBotTxns, 1500); }
- catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro (bot ativo?).", false); }
-}
-async function botRefund(id) {
- if (!confirm("Marcar pagamento #" + id + " como estornado?")) return;
- try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) }); loadBotTxns(); }
- catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro.", false); }
+ const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`);
+ botState.transactions = transactions || [];
+ renderBotTransactions(botState.transactions);
+ botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true);
+ botUpdateMetrics();
+ return transactions;
+ } catch (error) {
+ botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos.");
+ }
}
-// ─── Settings (messages) ───
+function botTransactionType(type) {
+ return ({ plan_purchase: "Compra de plano", plan_renewal: "Renovação", credit_topup: "Recarga" })[type] || type || "—";
+}
+
+function renderBotTransactions(transactions) {
+ const body = document.getElementById("botTxnsBody");
+ if (!body) return;
+ document.getElementById("botTxnCount").textContent = String(transactions.length);
+ if (!transactions.length) return botEmptyRow(body, 8, "Nenhum pagamento encontrado para este filtro.");
+ body.replaceChildren(...transactions.map(transaction => {
+ const row = document.createElement("tr");
+ const createdAt = transaction.CreatedAt ? new Date(transaction.CreatedAt).toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }) : "—";
+ const buttons = [];
+ if (transaction.Status === "pending" || transaction.Status === "approved") buttons.push(botButton("Reprocessar", () => botReprocess(transaction.ID)));
+ if (transaction.Status !== "refunded") buttons.push(botButton("Marcar estornado", () => botRefund(transaction.ID), "btn btn-danger btn-sm"));
+ row.append(
+ botCell(botPrimaryCell(`#${transaction.ID}`, transaction.MPPaymentID ? `MP ${transaction.MPPaymentID}` : "Sem ID Mercado Pago")),
+ botCell(transaction.TelegramID), botCell(botTransactionType(transaction.Type)), botCell(botBRL(transaction.AmountCents)),
+ botCell(botBadge(transaction.Status || "unknown", transaction.Status || "inactive")), botCell(transaction.TargetUsername || "Aguardando"),
+ botCell(createdAt), botCell(botActions(buttons)),
+ );
+ return row;
+ }));
+}
+
+async function botReprocess(id) {
+ try {
+ await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) });
+ botStatus("botTxnStatus", `Pagamento #${id} enviado para reprocessamento.`, true);
+ setTimeout(loadBotTxns, 1400);
+ } catch (error) {
+ botHandleError(error, "botTxnStatus", "Erro ao reprocessar pagamento.");
+ }
+}
+
+async function botRefund(id) {
+ if (!confirm(`Isso apenas marca o pagamento #${id} como estornado no painel. Não envia um estorno financeiro ao Mercado Pago. Continuar?`)) return;
+ try {
+ await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
+ await loadBotTxns();
+ } catch (error) {
+ botHandleError(error, "botTxnStatus", "Erro ao atualizar pagamento.");
+ }
+}
+
+// Messages
async function loadBotSettings() {
try {
- const res = await api("/api/bot/settings");
- const s = await res.json() || {};
- const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v || ""; };
- set("setWelcome", s.welcome_text); set("setContact", s.contact_text);
- set("setAppText", s.app_text); set("setAppUrl", s.app_url);
- botStatus("botSettingsStatus", "Ready.");
- } catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao carregar.", false); }
-}
-async function saveBotSettings() {
- const val = id => document.getElementById(id)?.value || "";
- const payload = { welcome_text: val("setWelcome"), contact_text: val("setContact"), app_text: val("setAppText"), app_url: val("setAppUrl") };
- try { await api("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) }); botStatus("botSettingsStatus", "Mensagens salvas."); }
- catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao salvar.", false); }
+ const settings = await botRequest("/api/bot/settings");
+ const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; };
+ set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text);
+ set("setAppText", settings.app_text); set("setAppUrl", settings.app_url);
+ botStatus("botSettingsStatus", "Mensagens carregadas.", true);
+ return settings;
+ } catch (error) {
+ botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens.");
+ }
}
-// ─── Wiring ───
+async function saveBotSettings() {
+ const value = id => document.getElementById(id)?.value || "";
+ const payload = { welcome_text: value("setWelcome"), contact_text: value("setContact"), app_text: value("setAppText"), app_url: value("setAppUrl").trim() };
+ botStatus("botSettingsStatus", "Salvando mensagens…");
+ try {
+ await botRequest("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) });
+ botStatus("botSettingsStatus", "Mensagens salvas.", true);
+ } catch (error) {
+ botHandleError(error, "botSettingsStatus", "Erro ao salvar mensagens.");
+ }
+}
+
+// Wiring
+document.querySelectorAll("[data-bot-section]").forEach(button => button.addEventListener("click", () => botSetSection(button.dataset.botSection)));
+document.getElementById("botSection")?.addEventListener("change", event => botSetSection(event.target.value));
document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
-document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig);
+document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab);
document.getElementById("botTestBtn")?.addEventListener("click", testBot);
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox);
-document.getElementById("botSection")?.addEventListener("change", e => botShowSection(e.target.value));
+document.getElementById("botCopyWebhookBtn")?.addEventListener("click", botCopyWebhook);
document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan);
document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs);
document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm);
+document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm);
document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg);
document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers);
document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns);
document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns);
document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings);
document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings);
+document.getElementById("botUserActionForm")?.addEventListener("submit", botSaveUserAction);
+document.getElementById("botActionRole")?.addEventListener("change", botToggleLinkedAdminField);
+document.querySelectorAll("[data-bot-modal-close]").forEach(element => element.addEventListener("click", botCloseUserAction));
+document.addEventListener("keydown", event => { if (event.key === "Escape") botCloseUserAction(); });
+
+botSetSection(botState.section);
diff --git a/admin/index.html b/admin/index.html
index ae79ce1..8d38052 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})();
-
+
@@ -951,234 +951,136 @@
-
-
-
-
-
🤖 Bot / Vendas
-
- ⚙️ Configuração
- 💎 Planos
- 💳 Pacotes de Crédito
- ✉️ Mensagens
- 👥 Clientes
- 💰 Pagamentos
-
+
+
+
Central de automação
+
Bot de vendas
+
Telegram, PIX, planos e clientes em um único fluxo.
+
+ Aguardando dados
+ Testar integrações
+ Atualizar tudo
+
+
+
● Operação Carregando
+
◆ Planos 0
+
↗ Clientes 0
+
$ PIX pendentes 0
+
+
+
+
+
+ ⚙ Integrações
+ ◆ Planos
+ ▤ Créditos
+ ✉ Mensagens
+ ◎ Clientes
+ $ Pagamentos
+
+
+ Integrações Planos Créditos
+ Mensagens Clientes Pagamentos
+
-
-
-
-
-
-
⚙️ Configuração do Bot
-
- Pronto.
- Testar conexão
- Recarregar
- Salvar configuração
-
-
-
1) Cole os tokens abaixo e clique Testar · 2) Crie os Planos · 3) Promova revendedores em Clientes . O Telegram funciona por long-polling — não precisa de domínio.
-
+
+ 01 · Integrações Conecte os serviços Tokens ficam criptografados. Campos vazios mantêm o valor atual.
Salvar e reiniciar bot
+
+
+
+ Token do @BotFather
+ IDs dos administradores Separe mais de um ID por vírgula.
+
-
-
-
-
-
-
-
-
Access Token
-
-
-
Webhook Secret
-
No Mercado Pago, cadastre a URL de notificação:https://SEU_HOST/api/mp/webhook
-
-
-
-
-
-
-
-
-
- Oferecer teste grátis
-
-
-
-
-
-
-
-
Host/IP para SSH
-
Host para links Xray
-
Enviado ao cliente nas credenciais após o pagamento.
-
-
-
-
-
-
-
-
-
-
-
💎 Planos 0
-
- + Novo
- Recarregar
-
-
-
-
- Nome Tipo Dias Preço Créd. Ativo Ações
-
-
-
-
Ready.
-
-
-
-
+
+
+
+ 02 · Catálogo Planos de acesso Configure preço, validade, protocolo e limites.
Atualizar Novo plano
+
+
Planos cadastrados 0
Plano Entrega Validade Preço Status
Aguardando.
+
Novo plano
Defina como a conta será criada.
Limpar
+
+
+
+
+
+
+ 03 · Revendedores Pacotes de créditos Crie recargas para revendedores comprarem planos.
Atualizar Novo pacote
+
+
Pacotes cadastrados 0
Pacote Créditos Preço Status
Aguardando.
+
Editar pacote
Defina o saldo entregue e o preço PIX.
+
+
+
+
+ 04 · Conteúdo Mensagens do bot Personalize os textos vistos pelo cliente.
Restaurar salvo
+ Mensagem de boas-vindas Use {name} para inserir o primeiro nome.
Contato e suporte
Texto para baixar o app
Link do aplicativo Somente links HTTP ou HTTPS.
Aguardando. Salvar mensagens
+
+
+
+ 05 · Relacionamento Clientes do Telegram Gerencie funções, vínculos e saldo sem sair da lista.
Atualizar clientes
+ Base de clientes Ações administrativas são registradas no livro de créditos.
0 Cliente Telegram ID Função Revenda vinculada Saldo
+
+
+
+ 06 · Financeiro Pagamentos PIX Acompanhe confirmações e reprocessamentos.
Todos os status Pendentes Aprovados Expirados Marcados como estornados Com erro Atualizar
+ Histórico recente 0
Pagamento Cliente Tipo Valor Status Entrega Data
Aguardando.
+
+
+
-
-
-
-
-
-
-
-
💳 Pacotes de Crédito 0
-
- + Novo
- Recarregar
-
-
-
-
- Nome Créditos Preço Ativo Ações
-
-
-
-
-
Ready.
-
-
-
-
-
-
-
-
-
✉️ Mensagens do Bot
-
Recarregar
-
-
Boas-vindas ({name} = nome)
-
Texto de Contato
-
Texto Baixar APP
-
URL do APP (botão vira link)
-
Salvar mensagens
-
Ready.
-
-
-
-
-
-
-
-
-
👥 Clientes do Bot 0
-
Recarregar
-
-
-
- Telegram ID Nome @user Papel Revenda Créditos Ações
-
-
-
-
-
-
-
-
-
-
-
-
-
💰 Pagamentos 0
-
-
- Todos
- Pendentes
- Aprovados
- Expirados
- Estornados
-
- Recarregar
-
-
-
-
- # Telegram Tipo Valor Status Conta Data Ações
-
-
-
-
Ready.
-
-
-
@@ -1494,17 +1396,17 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+