This commit is contained in:
2026-07-13 18:01:39 -03:00
parent dab8b09f0c
commit a345e70e5a
33 changed files with 3741 additions and 589 deletions
+10 -2
View File
@@ -5,6 +5,8 @@ if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
localStorage.removeItem("SESSION_TOKEN");
let currentRole = "";
let currentUser = "";
let currentQuotaMode = "slots";
let currentCreditBalance = 0;
let statsTimer = null, usersTimer = null, xrayTimer = null;
let tlsForwardersState = [];
let managedTlsForwardersState = [];
@@ -369,8 +371,13 @@ const resellerFormTitle = document.getElementById("resellerFormTitle");
const resellerForm = document.getElementById("resellerForm");
const rUsername = document.getElementById("rUsername");
const rPassword = document.getElementById("rPassword");
const rParent = document.getElementById("rParent");
const rQuotaMode = document.getElementById("rQuotaMode");
const rMaxUsers = document.getElementById("rMaxUsers");
const rCredits = document.getElementById("rCredits");
const rExpires = document.getElementById("rExpires");
const rWhatsApp = document.getElementById("rWhatsApp");
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
const rActive = document.getElementById("rActive");
// Managed servers
@@ -391,7 +398,6 @@ const serverFormStatus = document.getElementById("serverFormStatus");
const serversListView = document.getElementById("serversListView");
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
const cfgServerName = document.getElementById("cfgServerName");
const managedConfigEditor = document.getElementById("managedConfigEditor");
const managedConfigStatus = document.getElementById("managedConfigStatus");
const serversStatusGrid = document.getElementById("serversStatusGrid");
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
@@ -534,7 +540,9 @@ async function api(path, opts = {}) {
"X-Session-Token": sessionToken,
});
const res = await fetch(path, o);
if (res.status === 401 || res.status === 403) throw new Error("auth");
// A 403 is an in-session permission or quota error; only 401 means the
// session is no longer valid and should return to the login screen.
if (res.status === 401) throw new Error("auth");
return res;
}
function withServerParam(path, serverID) {
+50 -6
View File
@@ -153,7 +153,7 @@ function selectTab(tab) {
if (tab === "stats" && currentRole === "superadmin") loadStats();
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
if (tab === "resellers") loadResellers();
if (tab === "servers" && currentRole === "superadmin") loadServers();
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
}
@@ -218,6 +218,13 @@ function clearTimers() {
}
function initAfterLogin() {
if (currentRole === "superadmin") {
currentQuotaMode = "slots";
currentCreditBalance = 0;
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
if (input) { input.disabled = false; input.title = ""; }
});
}
meUsername.textContent = currentUser;
mainApp.classList.remove("role-superadmin", "role-reseller");
mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller");
@@ -276,13 +283,28 @@ async function loadMe() {
const res = await api("/api/auth/me");
const d = await res.json();
dashboardCache.me = d;
currentQuotaMode = d.quota_mode || "slots";
currentCreditBalance = d.credit_balance || 0;
const creditPlan = currentQuotaMode === "credits";
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
if (!input) return;
input.disabled = creditPlan;
input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : "";
});
const used = d.used_users ?? 0;
const max = d.max_users || 0;
rUsedMax.textContent = used + " / " + (max || "∞");
rUsedMax.textContent = currentQuotaMode === "credits"
? `${currentCreditBalance} créditos`
: `${used + (d.child_allocation || 0)} / ${max || "∞"}`;
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
rStatus.textContent = d.is_active ? t("Active") : t("Suspended");
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
const effectiveActive = d.effective_active ?? d.is_active;
rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)";
if (currentQuotaMode === "credits") {
updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0);
} else {
updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0);
}
renderDashboardCounters();
} catch {}
}
@@ -333,6 +355,24 @@ function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed});
}
function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) {
if (!dashQuotaText) return;
dashQuotaChip.textContent = `${balance} Cr`;
dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`;
dashQuotaText.textContent = `${balance} créditos disponíveis`;
dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`;
dashQuotaBar.style.width = "100%";
if (dashQuotaRemaining) {
dashQuotaRemaining.textContent = String(balance);
setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good");
}
if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`;
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%";
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`;
if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance);
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
}
function flattenXrayClients(inbounds = []) {
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
}
@@ -391,7 +431,11 @@ function renderDashboardCounters() {
const me = dashboardCache.me;
if (currentRole === "reseller" && me) {
updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
if ((me.quota_mode || "slots") === "credits") {
updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0);
} else {
updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
}
}
}
+46 -2
View File
@@ -1,4 +1,5 @@
// ─── SSH Users ────────────────────────────────────────────────────────────────
let editingSSHUser = "";
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
@@ -7,8 +8,13 @@ cancelUserBtn.addEventListener("click", () => {
setWorkspaceSection("ssh", "users");
});
function prepareNewSSHUser() {
editingSSHUser = "";
userForm.reset();
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
fMaxConn.disabled = false;
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
fMaxConn.value = currentRole === "reseller" ? "1" : "0";
fMaxConn.title = "";
const heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Create user");
@@ -87,6 +93,10 @@ function renderUsers(users) {
tr.appendChild(td);
});
const tdA = document.createElement("td");
const renewBtn = Object.assign(document.createElement("button"), {
className:"btn btn-ghost btn-sm", textContent:"+30d",
onclick: () => renewSSHUser(u),
});
const editBtn = Object.assign(document.createElement("button"), {
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillUserForm(u),
@@ -96,7 +106,8 @@ function renderUsers(users) {
style: "margin-left:4px;",
onclick: () => deleteUser(u.username),
});
tdA.append(editBtn, delBtn);
tdA.className = "bot-row-actions";
tdA.append(renewBtn, editBtn, delBtn);
tr.appendChild(tdA);
usersBody.appendChild(tr);
});
@@ -113,6 +124,7 @@ function renderUsers(users) {
}
function fillUserForm(u) {
editingSSHUser = u.username || "";
setWorkspaceSection("ssh", "create");
fUsername.value = u.username || "";
fPassword.value = "";
@@ -122,6 +134,10 @@ function fillUserForm(u) {
fTotpDigits.value = u.totp_digits || 6;
fAllowStatic.checked = !!u.allow_static_password;
fMaxConn.value = u.max_connections || "";
const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits";
fMaxConn.disabled = creditLocked;
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : "";
fUp.value = u.limit_mbps_up || "";
fDown.value = u.limit_mbps_down || "";
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
@@ -145,7 +161,9 @@ userForm.addEventListener("submit", async e => {
totp_digits: parseInt(fTotpDigits.value||"6",10),
allow_static_password: !!fAllowStatic.checked,
max_connections: parseInt(fMaxConn.value||"0",10),
expires_at: isoFromLocal(fExpires.value),
expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser
? ""
: isoFromLocal(fExpires.value),
limit_mbps_up: parseInt(fUp.value||"0",10),
limit_mbps_down: parseInt(fDown.value||"0",10),
server_id: selectedSSHServer(),
@@ -187,3 +205,29 @@ async function deleteUser(username) {
else userStatus.textContent = t("Error deleting.");
}
}
async function renewSSHUser(user) {
const creditCost = Math.max(1, Number(user.max_connections || 0));
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
const accepted = await panelConfirm({
icon:"+30", title:"Renovar SSH", message:`Renovar “${user.username}”?`,
detail:creditDetail, confirmLabel:"Renovar conta",
});
if (!accepted) return;
userStatus.textContent = `Renovando ${user.username}`;
try {
const res = await api("/api/users/renew", {
method:"POST",
body:JSON.stringify({ username:user.username, days:30, server_id:selectedSSHServer() }),
});
if (!res.ok) throw new Error((await res.text()).trim());
showPanelToast(`${user.username} renovado.`, "success", "SSH / SlowDNS");
await loadUsers();
if (currentRole === "reseller") loadMe();
} catch (e) {
if (e.message === "auth") doAuthError();
else showPanelToast(e.message, "error", "Renovar SSH");
}
}
+38 -2
View File
@@ -244,12 +244,17 @@ function renderInbounds(inbounds, options = {}) {
editBtn.style.marginLeft = "4px";
editBtn.textContent = t("Edit");
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
const renewBtn = document.createElement("button");
renewBtn.className = "btn btn-ghost btn-sm";
renewBtn.style.marginLeft = "4px";
renewBtn.textContent = "+30d";
renewBtn.onclick = () => renewXrayClient(c);
const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger btn-sm";
delBtn.style.marginLeft = "4px";
delBtn.textContent = t("Del");
delBtn.onclick = () => removeClient(ib.tag, c.id);
actTd.append(copyBtn, editBtn, delBtn);
actTd.append(copyBtn, renewBtn, editBtn, delBtn);
tr.appendChild(actTd);
tbody.appendChild(tr);
});
@@ -324,7 +329,10 @@ function prepareXrayClientCreator(preferredTag = "") {
const uuid = document.getElementById("xCreateUUID");
if (uuid) uuid.value = genUUID();
const maxConns = document.getElementById("xCreateMaxConns");
if (maxConns) maxConns.value = "0";
if (maxConns) {
maxConns.min = currentRole === "reseller" ? "1" : "0";
maxConns.value = currentRole === "reseller" ? "1" : "0";
}
const status = document.getElementById("xCreateClientStatus");
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
updateXrayCreatorInboundLabel();
@@ -405,6 +413,34 @@ async function removeClient(tag, uuid) {
}
}
async function renewXrayClient(client) {
const creditCost = Math.max(1, Number(client.max_conns || 0));
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
const accepted = await panelConfirm({
icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`,
detail:creditDetail, confirmLabel:"Renovar conta",
});
if (!accepted) return;
xStatus.textContent = "Renovando cliente Xray…";
try {
const res = await api("/api/xray/clients/renew", {
method:"POST",
body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
});
if (!res.ok) throw new Error((await res.text()).trim());
const data = await res.json();
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray");
else showPanelToast("Cliente Xray renovado.", "success", "Xray");
await loadInbounds({ force:true });
if (currentRole === "reseller") loadMe();
} catch (e) {
if (e.message === "auth") doAuthError();
else showPanelToast(e.message, "error", "Renovar Xray");
}
}
async function loadXrayCfg() {
if (!xCfgEditor) return;
const target = selectedXrayServerLabel();
+241 -71
View File
@@ -1,127 +1,297 @@
// ─── Resellers ────────────────────────────────────────────────────────────────
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
document.getElementById("newResellerBtn").addEventListener("click", () => navigateWorkspaceSection("resellers", "create"));
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
// ─── Hierarchical resellers ───────────────────────────────────────────────────
let resellersCache = [];
let editingReseller = "";
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
prepareNewReseller();
navigateWorkspaceSection("resellers", "create");
});
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
prepareNewReseller();
setWorkspaceSection("resellers", "users");
});
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => {
el.addEventListener("click", loadResellerAudit);
});
document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => {
if (e.target.value === "audit") loadResellerAudit();
});
rQuotaMode?.addEventListener("change", toggleResellerPlanFields);
function toggleResellerPlanFields() {
const credit = rQuotaMode.value === "credits";
document.getElementById("rSlotsField")?.classList.toggle("hidden", credit);
document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit);
document.getElementById("rExpiresField")?.classList.toggle("hidden", credit);
if (credit) rExpires.value = "";
}
function prepareNewReseller() {
resellerFormTitle.textContent = "Create Reseller";
editingReseller = "";
resellerFormTitle.textContent = t("Create Reseller");
const heading = document.getElementById("resellerFormHeading");
if (heading) heading.textContent = t("Create reseller");
resellerForm.reset();
rUsername.disabled = false;
rParent.disabled = false;
rQuotaMode.disabled = currentRole === "reseller";
rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots";
rMaxUsers.min = currentRole === "reseller" ? "1" : "0";
rMaxUsers.value = currentRole === "reseller" ? "1" : "30";
rCredits.value = "1";
rActive.checked = true;
resellerStatus.textContent = "New reseller.";
populateResellerParents();
toggleResellerPlanFields();
resellerStatus.textContent = t("New reseller.");
requestAnimationFrame(() => rUsername.focus());
}
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
async function loadResellers() {
resellerStatus.textContent = "Loading…";
resellerStatus.textContent = t("Loading…");
setResellerLiveStatus("Carregando revendedores…", "is-loading");
try {
const res = await api("/api/resellers");
const data = await res.json();
renderResellers(data || []);
resellerStatus.textContent = "Loaded.";
const res = await api("/api/resellers");
if (!res.ok) throw new Error(await res.text());
resellersCache = await res.json() || [];
renderResellers(resellersCache);
populateResellerParents();
resellerStatus.textContent = t("Loaded.");
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
} catch (e) {
if (e.message==="auth") doAuthError();
else resellerStatus.textContent = "Error loading.";
if (e.message === "auth") doAuthError();
else {
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
}
}
}
function setResellerLiveStatus(message, tone) {
const el = document.getElementById("resellerLiveStatus");
if (!el) return;
el.textContent = message;
el.className = `workspace-live-status ${tone || ""}`.trim();
}
function renderResellerMetrics(list) {
const active = list.filter(r => r.effective_active).length;
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
document.getElementById("resellerMetricTotal").textContent = String(list.length);
document.getElementById("resellerMetricActive").textContent = String(active);
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
document.getElementById("resellerMetricCredits").textContent = String(credits);
}
function renderResellers(list) {
resellerCountChip.textContent = list.length;
renderResellerMetrics(list);
resellersBody.innerHTML = "";
if (!list.length) {
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
return;
}
list.forEach(r => {
const expired = r.expires_at && new Date(r.expires_at) < new Date();
const max = r.max_users || 0;
const used = r.used_users || 0;
const remaining = max ? Math.max(0, max - used) : "∞";
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
const effective = !!r.effective_active && !expired;
const maxUsers = Number(r.max_users || 0);
const directUsed = Number(r.used_users || 0);
const childAllocation = Number(r.child_allocation || 0);
const committed = directUsed + childAllocation;
const remaining = maxUsers ? Math.max(0, maxUsers - committed) : "∞";
const pct = maxUsers ? Math.min(100, Math.round((committed / maxUsers) * 100)) : 0;
const isCredit = r.quota_mode === "credits";
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHTML(r.username)}</td>
<td>
<strong>${used} / ${max || "∞"}</strong>
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
<div class="table-meter"><span style="width:${pct}%"></span></div>
<div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
<small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
</div>
</td>
<td>${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
<td>
<strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
</td>
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
<td></td>`;
const tdA = tr.lastElementChild;
const editBtn = Object.assign(document.createElement("button"),{
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillResellerForm(r),
});
const delBtn = Object.assign(document.createElement("button"),{
className:"btn btn-danger btn-sm", textContent:t("Del"),
style: "margin-left:4px;",
onclick: () => deleteReseller(r.username),
});
tdA.append(editBtn, delBtn);
const actions = document.createElement("div");
actions.className = "bot-row-actions reseller-row-actions";
actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
if (!isCredit) actions.appendChild(resellerActionButton("+30d", "btn btn-ghost btn-sm", () => runResellerAction(r, "renew")));
if (currentRole === "superadmin" && r.parent_username) actions.appendChild(resellerActionButton("Puxar", "btn btn-ghost btn-sm", () => runResellerAction(r, "pull")));
actions.appendChild(resellerActionButton(r.is_active ? "Suspender" : "Reativar", r.is_active ? "btn btn-warn btn-sm" : "btn btn-ghost btn-sm", () => runResellerAction(r, r.is_active ? "suspend" : "reactivate")));
actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
tr.lastElementChild.appendChild(actions);
resellersBody.appendChild(tr);
});
}
function resellerActionButton(label, className, onclick) {
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
}
function populateResellerParents() {
if (!rParent) return;
const selected = rParent.value;
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
resellersCache
.filter(r => r.username !== editingReseller && r.effective_active)
.forEach(r => {
const option = document.createElement("option");
option.value = r.username;
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
rParent.appendChild(option);
});
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
}
function fillResellerForm(r) {
editingReseller = r.username;
setWorkspaceSection("resellers", "create");
resellerFormTitle.textContent = `Edit: ${r.username}`;
resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
const heading = document.getElementById("resellerFormHeading");
if (heading) heading.textContent = t("Edit reseller");
rUsername.value = r.username;
rPassword.value = "";
rMaxUsers.value = r.max_users || 0;
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
rActive.checked = r.is_active;
resellerStatus.textContent = `Editing ${r.username}.`;
rUsername.value = r.username;
rUsername.disabled = true;
rPassword.value = "";
populateResellerParents();
rParent.value = r.parent_username || "";
rParent.disabled = true;
rQuotaMode.value = r.quota_mode || "slots";
rQuotaMode.disabled = true;
rMaxUsers.value = r.max_users || 0;
rCredits.value = r.credit_balance || 0;
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
rWhatsApp.value = r.whatsapp || "";
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
rActive.checked = !!r.is_active;
toggleResellerPlanFields();
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
}
resellerForm.addEventListener("submit", async e => {
e.preventDefault();
const btn = document.getElementById("saveResellerBtn");
btn.disabled = true;
resellerStatus.textContent = "Saving…";
resellerStatus.textContent = t("Saving…");
const mode = rQuotaMode.value || "slots";
const payload = {
username: rUsername.value.trim(),
password: rPassword.value || undefined,
max_users: parseInt(rMaxUsers.value||"0",10),
expires_at: isoFromLocal(rExpires.value),
is_active: rActive.checked,
username: rUsername.value.trim(),
password: rPassword.value || undefined,
parent_username: currentRole === "superadmin" ? rParent.value : undefined,
quota_mode: mode,
max_users: parseInt(rMaxUsers.value || "0", 10),
credits: parseInt(rCredits.value || "0", 10),
expires_at: mode === "slots" ? isoFromLocal(rExpires.value) : "",
whatsapp: rWhatsApp.value.trim(),
monthly_price_cents: Math.round(Math.max(0, parseFloat(rMonthlyPrice.value || "0")) * 100),
is_active: rActive.checked,
};
try {
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
if (!res.ok) throw new Error(await res.text());
resellerStatus.textContent = "Saved.";
resellerForm.reset(); rActive.checked = true;
resellerFormTitle.textContent = "Create Reseller";
loadResellers();
const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
if (!res.ok) throw new Error((await res.text()).trim());
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
prepareNewReseller();
await loadResellers();
setWorkspaceSection("resellers", "users");
if (currentRole === "reseller") loadMe();
} catch (e) {
if (e.message==="auth") doAuthError();
else resellerStatus.textContent = "Error: "+e.message;
} finally { btn.disabled = false; }
if (e.message === "auth") doAuthError();
else {
resellerStatus.textContent = `${t("Error")}: ${e.message}`;
showPanelToast(e.message, "error", t("Resellers"));
}
} finally {
btn.disabled = false;
}
});
async function deleteReseller(username) {
async function runResellerAction(reseller, action) {
const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
const descriptions = {
renew: "A validade será estendida a partir da data atual ou da validade existente.",
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
};
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete reseller"),
message:t("Delete reseller \"{name}\"?", {name:username}),
detail:t("Their owned access will be removed and active SSH sessions will be disconnected."),
confirmLabel:t("Delete reseller"),
tone: action === "suspend" ? "danger" : "default",
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
title: labels[action],
message: `${labels[action]}${reseller.username}”?`,
detail: descriptions[action],
confirmLabel: labels[action],
});
if (!accepted) return;
resellerStatus.textContent = `Deleting ${username}`;
resellerStatus.textContent = `${labels[action]}`;
try {
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error("failed");
resellerStatus.textContent = "Deleted.";
loadResellers();
const res = await api("/api/resellers/action", {
method: "POST",
body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
});
if (!res.ok) throw new Error((await res.text()).trim());
const data = await res.json();
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", labels[action]);
else showPanelToast(`${reseller.username}: operação concluída.`, "success", labels[action]);
await loadResellers();
} catch (e) {
if (e.message==="auth") doAuthError();
else resellerStatus.textContent = "Error deleting.";
if (e.message === "auth") doAuthError();
else showPanelToast(e.message, "error", labels[action]);
}
}
async function deleteReseller(reseller) {
const accepted = await panelConfirm({
tone: "danger", icon: "×", title: t("Delete reseller"),
message: t("Delete reseller \"{name}\"?", {name: reseller.username}),
detail: `Serão removidos ${reseller.child_count || 0} sub-revendedores e todos os acessos SSH/Xray pertencentes à árvore. Esta ação não pode ser desfeita.`,
confirmLabel: t("Delete reseller"),
});
if (!accepted) return;
resellerStatus.textContent = t("Deleting {name}…", {name: reseller.username});
try {
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(reseller.username)}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) throw new Error((await res.text()).trim());
showPanelToast(`${reseller.username} removido.`, "success", t("Resellers"));
await loadResellers();
if (currentRole === "reseller") loadMe();
} catch (e) {
if (e.message === "auth") doAuthError();
else showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller"));
}
}
async function loadResellerAudit() {
const body = document.getElementById("resellerAuditBody");
if (!body) return;
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Carregando atividade…</td></tr>`;
try {
const res = await api("/api/resellers/audit");
if (!res.ok) throw new Error(await res.text());
const rows = await res.json() || [];
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhuma atividade registrada.</td></tr>`;
return;
}
rows.forEach(item => {
const tr = document.createElement("tr");
[fmtDate(item.created_at), item.actor_username, item.target_username, item.action, item.details || "—"].forEach(value => {
const td = document.createElement("td");
td.textContent = value;
tr.appendChild(td);
});
body.appendChild(tr);
});
} catch (e) {
if (e.message === "auth") doAuthError();
else body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Falha ao carregar a atividade.</td></tr>`;
}
}
+8 -2
View File
@@ -5,7 +5,11 @@ function openEditXrayClient(tag, client) {
document.getElementById("editXrayName").value = client.name || "";
document.getElementById("editXrayEmail").value = client.email || "";
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
const maxInput = document.getElementById("editXrayMaxConns");
maxInput.value = client.max_conns || 0;
maxInput.min = currentRole === "reseller" ? "1" : "0";
maxInput.disabled = currentRole === "reseller" && currentQuotaMode === "credits";
maxInput.title = maxInput.disabled ? "Em planos por crédito, o limite de conexões fica fixo." : "";
document.getElementById("editXrayClientStatus").textContent = "";
document.getElementById("editXrayClientPanel").classList.remove("hidden");
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
@@ -24,7 +28,9 @@ async function saveEditXrayClient() {
uuid: editingXrayClientId,
name: document.getElementById("editXrayName").value.trim(),
email: document.getElementById("editXrayEmail").value.trim(),
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
expires_at: currentRole === "reseller" && currentQuotaMode === "credits"
? ""
: isoFromLocal(document.getElementById("editXrayExpiry").value),
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
server_id: selectedXrayServer(),
};