// ─── SSH Users ────────────────────────────────────────────────────────────────
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
cancelUserBtn.addEventListener("click", () => {
prepareNewSSHUser();
setWorkspaceSection("ssh", "users");
});
function prepareNewSSHUser() {
userForm.reset();
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
// SSH and SSH-over-XHTTP plans normally remain connected after quota and
// fall back to the configured post-quota speed. Existing users keep their
// saved action when edited.
fQuotaAction.value = "throttle";
fQuotaThrottle.value = 1;
fUsageDisplay.value = "0 B";
fResetUsage.checked = false;
const heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Create user");
if (title) title.textContent = t("Create SSH user");
userStatus.textContent = t("New user.");
requestAnimationFrame(() => fUsername.focus());
}
document.getElementById("genTotpBtn").addEventListener("click", () => {
fTotpSecret.value = genBase32();
if (!fTotpPeriod.value) fTotpPeriod.value = 60;
if (!fTotpWindow.value) fTotpWindow.value = 1;
if (!fTotpDigits.value) fTotpDigits.value = 6;
userStatus.textContent = t("TOTP secret generated.");
});
document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
async function loadUsers() {
userStatus.textContent = t("Loading…");
if (sshLiveStatus) {
sshLiveStatus.textContent = t("Loading SSH status…");
sshLiveStatus.className = "workspace-live-status is-loading";
}
if (sshMetricState) sshMetricState.textContent = t("Loading");
try {
const res = await api(withServerParam("/api/users", selectedSSHServer()));
const data = await res.json();
renderUsers(data || []);
userStatus.textContent = t("Loaded.");
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
} catch (e) {
if (sshLiveStatus) {
sshLiveStatus.textContent = t("Could not load SSH status");
sshLiveStatus.className = "workspace-live-status is-error";
}
if (sshMetricState) sshMetricState.textContent = t("Error");
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); }
}
}
async function loadUsersSilent() {
try {
const res = await api(withServerParam("/api/users", selectedSSHServer()));
const data = await res.json();
renderUsers(data || []);
} catch (e) {
if (e.message==="auth") doAuthError();
}
}
// ---- Column sorting (click a header to sort) ----
// Value extractor per sortable column. Numbers sort numerically, strings
// alphabetically; online counts as 1 so "status" groups online users together.
const USER_SORT_EXTRACT = {
username: u => String(u.username || "").toLowerCase(),
status: u => (u.active_conns || 0) > 0 ? 1 : 0,
auth: u => u.use_pam ? "pam" : (u.totp_enabled ? (u.allow_static_password ? "totp+pw" : "totp") : "password"),
conn: u => u.active_conns || 0,
max: u => u.max_connections || 0,
up: u => u.limit_mbps_up || 0,
down: u => u.limit_mbps_down || 0,
speed: u => speedTotalBytesPerSec(u),
usage: u => Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0),
expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity,
owner: u => String(u.owner_username || "").toLowerCase(),
};
// Columns that default to descending on first click (most/online first).
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "speed", "usage"]);
const USER_SORT_OPTIONS = [
["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"],
["speed", "Speed"], ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"],
["up", "Up"], ["down", "Dn"], ["owner", "Owner"],
];
const USER_FILTER_OPTIONS = [
["all", "All"], ["online", "Online"], ["offline", "Offline"],
["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"],
];
let userSort = { key: "username", dir: "asc" };
let userFilter = "all";
let lastUsersData = [];
function userMatchesFilter(user) {
const online = Number(user.active_conns || 0) > 0;
const expired = isExpiredDate(user.expires_at);
switch (userFilter) {
case "online": return online;
case "offline": return !online;
case "active": return !expired;
case "expired": return expired;
case "quota": return !!user.quota_exceeded;
default: return true;
}
}
function sortUsers(list) {
const ext = USER_SORT_EXTRACT[userSort.key] || USER_SORT_EXTRACT.username;
const dir = userSort.dir === "desc" ? -1 : 1;
return list.slice().sort((a, b) => {
const va = ext(a), vb = ext(b);
let cmp;
if (typeof va === "number" && typeof vb === "number") cmp = va - vb;
else cmp = String(va).localeCompare(String(vb));
// Stable tie-break by username so equal rows never shuffle between polls.
if (cmp === 0) cmp = String(a.username || "").localeCompare(String(b.username || ""));
return cmp * dir;
});
}
function updateSortIndicators() {
const table = usersBody && usersBody.closest("table");
if (!table) return;
table.querySelectorAll("th[data-sort-key]").forEach(th => {
th.classList.remove("sort-asc", "sort-desc");
if (th.getAttribute("data-sort-key") === userSort.key) {
th.classList.add(userSort.dir === "asc" ? "sort-asc" : "sort-desc");
}
});
}
function renderSSHListControls() {
const sortLabel = document.getElementById("sshSortLabel");
const filterLabel = document.getElementById("sshFilterLabel");
const sortButtons = document.getElementById("sshSortButtons");
const filterButtons = document.getElementById("sshFilterButtons");
const count = document.getElementById("sshListCount");
if (sortLabel) sortLabel.textContent = t("Sort by");
if (filterLabel) filterLabel.textContent = t("Show");
if (sortButtons) {
const options = USER_SORT_OPTIONS.filter(([key]) => key !== "owner" || currentRole === "superadmin");
sortButtons.replaceChildren(...options.map(([key, label]) => {
const button = document.createElement("button");
button.type = "button";
button.className = "user-list-filter-btn" + (userSort.key === key ? " active" : "");
button.textContent = t(label);
button.setAttribute("aria-pressed", userSort.key === key ? "true" : "false");
if (userSort.key === key) button.dataset.direction = userSort.dir;
button.addEventListener("click", () => setUserSort(key));
return button;
}));
}
if (filterButtons) {
filterButtons.replaceChildren(...USER_FILTER_OPTIONS.map(([key, label]) => {
const button = document.createElement("button");
button.type = "button";
button.className = "user-list-filter-btn" + (userFilter === key ? " active" : "");
button.textContent = t(label);
button.setAttribute("aria-pressed", userFilter === key ? "true" : "false");
button.addEventListener("click", () => setUserFilter(key));
return button;
}));
}
if (count) {
const visible = lastUsersData.filter(userMatchesFilter).length;
count.textContent = t("{visible} of {total} users", {visible, total:lastUsersData.length});
}
}
function setUserSort(key) {
if (!USER_SORT_EXTRACT[key]) return;
if (userSort.key === key) {
userSort.dir = userSort.dir === "asc" ? "desc" : "asc";
} else {
userSort.key = key;
userSort.dir = USER_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
}
renderUsers(lastUsersData);
}
function setUserFilter(filter) {
if (!USER_FILTER_OPTIONS.some(([key]) => key === filter)) return;
userFilter = filter;
renderUsers(lastUsersData);
}
(function initUserSortHeaders() {
const table = usersBody && usersBody.closest("table");
if (!table) return;
table.querySelectorAll("th[data-sort-key]").forEach(th => {
th.addEventListener("click", () => setUserSort(th.getAttribute("data-sort-key")));
});
updateSortIndicators();
})();
renderSSHListControls();
function sshTrafficHTML(u) {
const up = Number(u.total_uplink_bytes || 0);
const down = Number(u.total_downlink_bytes || 0);
const total = Number(u.total_bytes || (up + down) || 0);
const quota = Number(u.data_quota_bytes || 0);
const quotaLabel = quota > 0 ? formatBytes(quota) : "∞";
const state = u.quota_exceeded
? (u.quota_action === "throttle" ? ` · ${t("throttled")}` : ` · ${t("blocked")}`)
: "";
return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}
↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}
`;
}
function renderUsers(users) {
// Cache the raw list so a header click can re-sort without refetching, and
// order by the active column so rows don't shuffle on each live poll.
lastUsersData = Array.isArray(users) ? users : [];
users = sortUsers(lastUsersData.filter(userMatchesFilter));
updateDashboardFromUsers(lastUsersData);
renderSSHListControls();
updateSortIndicators();
const isSA = currentRole === "superadmin";
ownerColHead.classList.toggle("hidden", !isSA);
usersBody.innerHTML = "";
const online = lastUsersData.filter(u => Number(u.active_conns || 0) > 0).length;
const expiredCount = lastUsersData.filter(u => isExpiredDate(u.expires_at)).length;
users.forEach(u => {
const on = (u.active_conns || 0) > 0;
const tr = document.createElement("tr");
// Every cell carries its column label so the table can collapse into
// labelled cards on phones instead of scrolling sideways. "wide" cells span
// the full card width there.
const cells = [
{ label:"User", text:u.username, cls:"cell-primary" },
{ label:"Status", html: on ? `${t("online")}` : `${t("idle")}` },
{ label:"Auth", text: u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password") },
{ label:"Conn", text: String(u.active_conns ?? 0) },
{ label:"Max", text: String(u.max_connections || 0) },
// "Up"/"Dn" are speed limits, not current speed: spell that out on the
// card layout where the label sits right next to the live speed.
{ label:"Up", cardLabel:"Limit up", text: String(u.limit_mbps_up || 0) },
{ label:"Dn", cardLabel:"Limit down", text: String(u.limit_mbps_down || 0) },
{ label:"Speed", html: speedHTML(u.up_bytes_per_sec, u.down_bytes_per_sec), small:true, cls:"cell-wide" },
{ label:"Traffic", html: sshTrafficHTML(u), small:true, cls:"cell-wide" },
{ label:"Expires", text: u.expires_at ? fmtDate(u.expires_at) : "—" },
];
if (isSA) cells.push({ label:"Owner", text: u.owner_username || "—" });
cells.forEach(cell => {
const td = document.createElement("td");
td.dataset.label = t(cell.cardLabel || cell.label);
if (cell.cls) td.className = cell.cls;
if (cell.html !== undefined) td.innerHTML = cell.html;
else td.textContent = cell.text ?? "—";
if (cell.small) td.style.fontSize = ".7rem";
tr.appendChild(td);
});
const tdA = document.createElement("td");
tdA.dataset.label = t("Actions");
tdA.className = "cell-actions";
const editBtn = Object.assign(document.createElement("button"), {
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillUserForm(u),
});
const resetBtn = Object.assign(document.createElement("button"), {
className:"btn btn-warn btn-sm", textContent:t("Reset"),
style: "margin-left:4px;",
title: t("Reset traffic"),
onclick: () => resetUserTraffic(u.username, resetBtn),
});
const delBtn = Object.assign(document.createElement("button"), {
className:"btn btn-danger btn-sm", textContent:t("Del"),
style: "margin-left:4px;",
onclick: () => deleteUser(u.username),
});
tdA.append(editBtn, resetBtn, delBtn);
tr.appendChild(tdA);
usersBody.appendChild(tr);
});
if (!users.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = isSA ? 12 : 11;
cell.className = "hint";
cell.style.cssText = "padding:24px;text-align:center;";
cell.textContent = t("No SSH users match this filter.");
row.appendChild(cell);
usersBody.appendChild(row);
}
const activeCount = Math.max(0, lastUsersData.length - expiredCount);
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count:lastUsersData.length, active:activeCount, online});
if (sshMetricTotal) sshMetricTotal.textContent = String(lastUsersData.length);
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
if (sshMetricState) sshMetricState.textContent = t("Online");
if (sshLiveStatus) {
sshLiveStatus.textContent = t("SSH data updated at {time}", {time:new Date().toLocaleTimeString()});
sshLiveStatus.className = "workspace-live-status is-ok";
}
}
function fillUserForm(u) {
setWorkspaceSection("ssh", "create");
fUsername.value = u.username || "";
fPassword.value = "";
fTotpSecret.value = u.totp_secret || "";
fTotpPeriod.value = u.totp_period || 60;
fTotpWindow.value = u.totp_window ?? 1;
fTotpDigits.value = u.totp_digits || 6;
fAllowStatic.checked = !!u.allow_static_password;
fMaxConn.value = u.max_connections || "";
fUp.value = u.limit_mbps_up || "";
fDown.value = u.limit_mbps_down || "";
fQuotaGB.value = u.data_quota_bytes ? (Number(u.data_quota_bytes) / (1024 ** 3)).toFixed(2).replace(/\.00$/, "") : "0";
fQuotaAction.value = u.quota_action === "throttle" ? "throttle" : "block";
fQuotaThrottle.value = u.quota_throttle_mbps || 1;
const totalBytes = Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0);
fUsageDisplay.value = `${formatBytes(totalBytes)} (↑ ${formatBytes(u.total_uplink_bytes || 0)} · ↓ ${formatBytes(u.total_downlink_bytes || 0)})`;
fResetUsage.checked = false;
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
const heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Edit user");
if (title) title.textContent = t("Editing {name}", {name:u.username});
userStatus.textContent = t("Editing {name}", {name: u.username});
}
userForm.addEventListener("submit", async e => {
e.preventDefault();
saveUserBtn.disabled = true;
userStatus.textContent = t("Saving…");
const payload = {
username: fUsername.value.trim(),
password: fPassword.value || undefined,
totp_secret: fTotpSecret.value.trim(),
totp_period: parseInt(fTotpPeriod.value||"60",10),
totp_window: parseInt(fTotpWindow.value||"1",10),
totp_digits: parseInt(fTotpDigits.value||"6",10),
allow_static_password: !!fAllowStatic.checked,
max_connections: parseInt(fMaxConn.value||"0",10),
expires_at: isoFromLocal(fExpires.value),
limit_mbps_up: parseInt(fUp.value||"0",10),
limit_mbps_down: parseInt(fDown.value||"0",10),
data_quota_bytes: Math.round((parseFloat(fQuotaGB.value || "0") || 0) * (1024 ** 3)),
quota_action: fQuotaAction.value === "throttle" ? "throttle" : "block",
quota_throttle_mbps: parseInt(fQuotaThrottle.value || "1", 10) || 1,
reset_usage: !!fResetUsage.checked,
server_id: selectedSSHServer(),
};
try {
const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
if (!res.ok) throw new Error(await res.text());
userStatus.textContent = t("Saved.");
fPassword.value = "";
fResetUsage.checked = false;
loadUsers();
if (currentRole === "reseller") loadMe();
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
setWorkspaceSection("ssh", "users");
} catch (e) {
if (e.message==="auth") doAuthError();
else userStatus.textContent = t("Error: {error}", {error: e.message});
} finally {
saveUserBtn.disabled = false;
}
});
async function resetUserTraffic(username, button) {
const accepted = await panelConfirm({
tone:"warning", icon:"↺", title:t("Reset SSH traffic"),
message:t("Reset traffic for user \"{name}\"?", {name: username}),
detail:t("Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged."),
confirmLabel:t("Reset traffic"),
});
if (!accepted) return;
const previousDisabled = !!button?.disabled;
if (button) button.disabled = true;
userStatus.textContent = t("Resetting traffic for {name}…", {name: username});
try {
const res = await api("/api/users/reset-traffic", {
method:"POST",
body: JSON.stringify({ username, server_id:selectedSSHServer() }),
});
if (!res.ok) throw new Error((await res.text()) || "reset failed");
userStatus.textContent = t("Traffic reset successfully.");
showPanelToast(t("Traffic reset successfully."), "success", t("SSH / SlowDNS"));
await loadUsers();
} catch (e) {
if (e.message === "auth") doAuthError();
else {
userStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("SSH / SlowDNS"));
}
} finally {
if (button) button.disabled = previousDisabled;
}
}
async function deleteUser(username) {
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete SSH account"),
message:t("Delete user \"{name}\"?", {name: username}),
detail:t("The active SSH sessions for this account will be disconnected."),
confirmLabel:t("Delete account"),
});
if (!accepted) return;
userStatus.textContent = t("Deleting {name}…", {name: username});
try {
const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error("delete failed");
userStatus.textContent = t("Deleted.");
loadUsers();
if (currentRole === "reseller") loadMe();
} catch (e) {
if (e.message==="auth") doAuthError();
else userStatus.textContent = t("Error deleting.");
}
}