// ─── SSH Users ────────────────────────────────────────────────────────────────
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => {
setFormCollapsed(false);
userForm.reset();
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
userStatus.textContent = t("New user.");
fUsername.focus();
});
cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
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 = ""; });
function setFormCollapsed(v) {
formCollapsed = v;
userFormWrap.classList.toggle("collapsed", v);
toggleFormBtn.textContent = v ? t("Show form") : t("Hide form");
}
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();
}
}
function renderUsers(users) {
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 tr = document.createElement("tr");
const cells = [
u.username,
on ? `${t("online")}` : `${t("idle")}`,
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,
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) {
setFormCollapsed(false);
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 || "";
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
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),
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 = "";
loadUsers();
if (currentRole === "reseller") loadMe();
} 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.");
}
}