// ─── 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, 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", "usage"]); const USER_SORT_OPTIONS = [ ["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"], ["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)}