Files
DragonCoreSSH-NewWEB/admin/assets/js/05-resellers.js
T
2026-07-13 18:01:39 -03:00

298 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── 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() {
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;
populateResellerParents();
toggleResellerPlanFields();
resellerStatus.textContent = t("New reseller.");
requestAnimationFrame(() => rUsername.focus());
}
async function loadResellers() {
resellerStatus.textContent = t("Loading…");
setResellerLiveStatus("Carregando revendedores…", "is-loading");
try {
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 = `${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 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>
<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>
<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 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 = `${t("Edit")}: ${r.username}`;
const heading = document.getElementById("resellerFormHeading");
if (heading) heading.textContent = t("Edit reseller");
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 = t("Saving…");
const mode = rQuotaMode.value || "slots";
const payload = {
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()).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 = `${t("Error")}: ${e.message}`;
showPanelToast(e.message, "error", t("Resellers"));
}
} finally {
btn.disabled = false;
}
});
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: 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 = `${labels[action]}…`;
try {
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 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>`;
}
}