283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
// ─── 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;
|
||
fQuotaAction.value = "block";
|
||
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"]);
|
||
|
||
let userSort = { key: "username", dir: "asc" };
|
||
let lastUsersData = [];
|
||
|
||
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 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";
|
||
}
|
||
updateSortIndicators();
|
||
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();
|
||
})();
|
||
|
||
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 = users || [];
|
||
users = sortUsers(lastUsersData);
|
||
updateDashboardFromUsers(users);
|
||
const isSA = currentRole === "superadmin";
|
||
userCountChip.textContent = users.length;
|
||
if (isSA) ownerColHead.classList.remove("hidden");
|
||
usersBody.innerHTML = "";
|
||
let online = 0;
|
||
let expiredCount = 0;
|
||
users.forEach(u => {
|
||
const on = (u.active_conns || 0) > 0;
|
||
if (on) online++;
|
||
if (isExpiredDate(u.expires_at)) expiredCount++;
|
||
const totalBytes = Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0);
|
||
const quotaBytes = Number(u.data_quota_bytes || 0);
|
||
const quotaLabel = quotaBytes > 0 ? formatBytes(quotaBytes) : "∞";
|
||
const quotaState = u.quota_exceeded
|
||
? (u.quota_action === "throttle" ? ` · ${t("throttled")}` : ` · ${t("blocked")}`)
|
||
: "";
|
||
const tr = document.createElement("tr");
|
||
const cells = [
|
||
u.username,
|
||
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`,
|
||
u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password"),
|
||
u.active_conns ?? 0,
|
||
u.max_connections || 0,
|
||
u.limit_mbps_up || 0,
|
||
u.limit_mbps_down || 0,
|
||
`${formatBytes(totalBytes)} / ${quotaLabel}${quotaState}`,
|
||
u.expires_at ? fmtDate(u.expires_at) : "—",
|
||
];
|
||
if (isSA) cells.push(u.owner_username || "—");
|
||
cells.forEach((c, i) => {
|
||
const td = document.createElement("td");
|
||
if (i === 1) td.innerHTML = c; else td.textContent = c;
|
||
tr.appendChild(td);
|
||
});
|
||
const tdA = document.createElement("td");
|
||
const editBtn = Object.assign(document.createElement("button"), {
|
||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||
onclick: () => fillUserForm(u),
|
||
});
|
||
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, delBtn);
|
||
tr.appendChild(tdA);
|
||
usersBody.appendChild(tr);
|
||
});
|
||
const activeCount = Math.max(0, users.length - expiredCount);
|
||
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
|
||
if (sshMetricTotal) sshMetricTotal.textContent = String(users.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 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.");
|
||
}
|
||
}
|