Files
ssh-jumphost/static/js/admin.js
2026-08-20 10:43:19 +02:00

724 lines
26 KiB
JavaScript

(() => {
"use strict";
const ROLE_NAMES = [
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
];
const bannerBox = document.getElementById("banner-box");
function showBanner(message, type) {
const div = document.createElement("div");
div.className = `banner ${type === "error" ? "error" : "ok"}`;
div.textContent = message;
bannerBox.replaceChildren(div);
window.setTimeout(() => {
if (bannerBox.contains(div)) bannerBox.removeChild(div);
}, 8000);
}
async function apiFetch(url, options = {}) {
const res = await fetch(url, { credentials: "same-origin", ...options });
if (res.status === 401) {
window.location.href = "/";
throw new Error("nicht angemeldet");
}
let data = null;
try {
data = await res.json();
} catch (_err) {
data = null;
}
if (!res.ok) {
const detail = (data && data.detail) || `Fehler ${res.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data;
}
function getJson(url) {
return apiFetch(url);
}
function sendJson(url, method, body) {
return apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === "className") node.className = v;
else if (k === "textContent") node.textContent = v;
else node.setAttribute(k, v);
}
for (const child of children) node.appendChild(child);
return node;
}
function actionButton(label, className, handler) {
const btn = el("button", { type: "button", className: `btn-small ${className}` });
btn.textContent = label;
btn.addEventListener("click", handler);
return btn;
}
function fillTable(tbody, rows) {
tbody.replaceChildren(...rows);
}
function optionsFromList(items, valueKey, labelFn) {
return items.map((item) => {
const opt = document.createElement("option");
opt.value = item[valueKey];
opt.textContent = labelFn(item);
return opt;
});
}
// ---------------------------------------------------------------------
// Tabs
// ---------------------------------------------------------------------
const tabLoaders = {
users: loadUsersTab,
groups: loadGroupsTab,
hosts: loadHostsTab,
roles: loadRolesTab,
tokens: loadTokensTab,
audit: loadAuditTab,
};
const loadedTabs = new Set();
document.getElementById("tabs").addEventListener("click", (ev) => {
const btn = ev.target.closest(".tab-btn");
if (!btn) return;
const tab = btn.dataset.tab;
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.toggle("active", b === btn));
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.classList.toggle("hidden", panel.id !== `tab-${tab}`);
});
if (!loadedTabs.has(tab)) {
loadedTabs.add(tab);
tabLoaders[tab]().catch((err) => showBanner(err.message, "error"));
}
});
// ---------------------------------------------------------------------
// Benutzer
// ---------------------------------------------------------------------
async function loadUsersTab() {
await refreshUsers();
}
let cachedUsers = [];
async function refreshUsers() {
cachedUsers = await getJson("/admin/users");
const tbody = document.querySelector("#users-table tbody");
fillTable(
tbody,
cachedUsers.map((u) => {
const actions = el("td", {});
if (u.is_active) {
actions.appendChild(
actionButton("Deaktivieren", "btn-danger", async () => {
await sendJson(`/admin/users/${u.id}/deactivate`, "POST", {});
showBanner(`Benutzer '${u.username}' deaktiviert.`, "ok");
await refreshUsers();
})
);
}
return el("tr", {}, [
el("td", { textContent: String(u.id) }),
el("td", { textContent: u.username }),
el("td", { textContent: u.is_admin ? "ja" : "nein" }),
el("td", { textContent: u.is_active ? "ja" : "nein" }),
el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }),
el("td", { textContent: u.created_at }),
actions,
]);
})
);
}
document.getElementById("user-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const username = document.getElementById("uc-username").value.trim();
const password = document.getElementById("uc-password").value;
const isAdmin = document.getElementById("uc-is-admin").checked;
await sendJson("/admin/users", "POST", {
username, initial_password: password, is_admin: isAdmin,
});
showBanner(`Benutzer '${username}' angelegt.`, "ok");
ev.target.reset();
await refreshUsers();
await refreshSharedSelects();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Benutzergruppen
// ---------------------------------------------------------------------
let cachedGroups = [];
let activeGroupId = null;
async function loadGroupsTab() {
await refreshGroups();
}
async function refreshGroups() {
cachedGroups = await getJson("/admin/user-groups");
const tbody = document.querySelector("#groups-table tbody");
fillTable(
tbody,
cachedGroups.map((g) =>
el("tr", {}, [
el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }),
el("td", { textContent: String(g.member_count) }),
el("td", {}, [
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
]),
])
)
);
}
async function showGroupMembers(group) {
activeGroupId = group.id;
document.getElementById("group-members-panel").classList.remove("hidden");
document.getElementById("group-members-name").textContent = group.name;
await refreshGroupMembers();
const select = document.getElementById("gm-user-select");
select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`));
}
async function refreshGroupMembers() {
if (activeGroupId === null) return;
const members = await getJson(`/admin/user-groups/${activeGroupId}/members`);
const tbody = document.querySelector("#group-members-table tbody");
fillTable(
tbody,
members.map((m) =>
el("tr", {}, [
el("td", { textContent: String(m.user_id) }),
el("td", { textContent: m.username }),
el("td", { textContent: m.added_at }),
el("td", {}, [
actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/user-groups/${activeGroupId}/members/${m.user_id}`, { method: "DELETE" });
await refreshGroupMembers();
await refreshGroups();
}),
]),
])
)
);
}
document.getElementById("group-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const name = document.getElementById("gc-name").value.trim();
const description = document.getElementById("gc-description").value.trim() || null;
await sendJson("/admin/user-groups", "POST", { name, description });
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshGroups();
await refreshSharedSelects();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("group-member-add-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const userId = Number(document.getElementById("gm-user-select").value);
await sendJson(`/admin/user-groups/${activeGroupId}/members`, "POST", { user_id: userId });
showBanner("Mitglied hinzugefuegt.", "ok");
await refreshGroupMembers();
await refreshGroups();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Hosts & Verbindungen
// ---------------------------------------------------------------------
let cachedHostGroups = [];
let cachedHosts = [];
let cachedSshKeys = [];
let activeHostId = null;
async function loadHostsTab() {
await Promise.all([refreshHostGroups(), refreshSshKeys()]);
await refreshHosts();
}
async function refreshHostGroups() {
cachedHostGroups = await getJson("/admin/host-groups");
const tbody = document.querySelector("#hostgroups-table tbody");
fillTable(
tbody,
cachedHostGroups.map((g) =>
el("tr", {}, [
el("td", { textContent: String(g.id) }),
el("td", { textContent: g.name }),
el("td", { textContent: g.description || "" }),
])
)
);
const hcSelect = document.getElementById("hc-hostgroup");
hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`));
}
async function refreshHosts() {
cachedHosts = await getJson("/admin/hosts");
const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`;
const tbody = document.querySelector("#hosts-table tbody");
fillTable(
tbody,
cachedHosts.map((h) =>
el("tr", {}, [
el("td", { textContent: String(h.id) }),
el("td", { textContent: h.hostname }),
el("td", { textContent: h.address }),
el("td", { textContent: h.protocol }),
el("td", { textContent: h.os_type }),
el("td", { textContent: groupName(h.host_group_id) }),
el("td", {}, [actionButton("Details", "btn-secondary", () => showHostDetail(h))]),
])
)
);
}
async function refreshSshKeys() {
cachedSshKeys = await getJson("/admin/ssh-keys");
const tbody = document.querySelector("#ssh-keys-table tbody");
fillTable(
tbody,
cachedSshKeys.map((k) =>
el("tr", {}, [
el("td", { textContent: String(k.id) }),
el("td", { textContent: k.label }),
el("td", { textContent: k.key_type }),
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
el("td", { textContent: k.created_at }),
])
)
);
}
function showHostDetail(host) {
activeHostId = host.id;
document.getElementById("host-detail-panel").classList.remove("hidden");
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
document.getElementById("host-detail-ssh").classList.toggle("hidden", host.protocol !== "ssh");
document.getElementById("host-detail-rdp").classList.toggle("hidden", host.protocol !== "rdp");
document.getElementById("host-key-result").classList.add("hidden");
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
}
document.getElementById("hc-protocol").addEventListener("change", (ev) => {
const isRdp = ev.target.value === "rdp";
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-port").value = isRdp ? "3389" : "22";
document.getElementById("hc-os-type").value = isRdp ? "windows" : "linux";
});
document.getElementById("hostgroup-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const name = document.getElementById("hgc-name").value.trim();
const description = document.getElementById("hgc-description").value.trim() || null;
await sendJson("/admin/host-groups", "POST", { name, description });
showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshHostGroups();
await refreshSharedSelects();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("host-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const protocol = document.getElementById("hc-protocol").value;
const payload = {
host_group_id: Number(document.getElementById("hc-hostgroup").value),
hostname: document.getElementById("hc-hostname").value.trim(),
address: document.getElementById("hc-address").value.trim(),
protocol,
port: Number(document.getElementById("hc-port").value),
os_type: document.getElementById("hc-os-type").value,
ssh_username: document.getElementById("hc-ssh-username").value.trim() || null,
rdp_username: document.getElementById("hc-rdp-username").value.trim() || null,
rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null,
rdp_require_nla: document.getElementById("hc-nla").checked,
clipboard_enabled: document.getElementById("hc-clipboard").checked,
file_transfer_enabled: document.getElementById("hc-filetransfer").checked,
};
await sendJson("/admin/hosts", "POST", payload);
showBanner(`Verbindung '${payload.hostname}' angelegt.`, "ok");
ev.target.reset();
document.getElementById("hc-protocol").dispatchEvent(new Event("change"));
await refreshHosts();
await refreshSharedSelects();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("host-discover-key-btn").addEventListener("click", async () => {
try {
const result = await sendJson(`/admin/hosts/${activeHostId}/discover-host-key`, "POST", {});
const box = document.getElementById("host-key-result");
box.textContent = `Fingerprint: ${result.fingerprint}`;
box.classList.remove("hidden");
showBanner("Host-Key ermittelt und gespeichert.", "ok");
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("host-key-map-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const keyId = document.getElementById("hkm-key-select").value;
await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {});
showBanner("SSH-Key zugeordnet.", "ok");
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("host-rdp-cred-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const password = document.getElementById("hrc-password").value;
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
ev.target.reset();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const ownerRaw = document.getElementById("skc-owner").value.trim();
const payload = {
label: document.getElementById("skc-label").value.trim(),
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
key_type: document.getElementById("skc-type").value,
private_key_pem: document.getElementById("skc-private").value,
public_key: document.getElementById("skc-public").value,
};
await sendJson("/admin/ssh-keys", "POST", payload);
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
ev.target.reset();
await refreshSshKeys();
if (activeHostId !== null) {
const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
}
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Rollen
// ---------------------------------------------------------------------
function roleOptions() {
return ROLE_NAMES.map((name) => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
return opt;
});
}
async function loadRolesTab() {
document.getElementById("rg-role").replaceChildren(...roleOptions());
document.getElementById("grg-role").replaceChildren(...roleOptions());
await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
}
async function refreshRoleGrants() {
const grants = await getJson("/admin/roles");
const tbody = document.querySelector("#role-grants-table tbody");
fillTable(
tbody,
grants.map((g) =>
el("tr", {}, [
el("td", { textContent: g.username }),
el("td", { textContent: g.host_group_name }),
el("td", { textContent: g.role_name }),
el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson("/admin/roles/revoke", "POST", {
user_id: g.user_id, host_group_id: g.host_group_id, role_name: g.role_name,
});
await refreshRoleGrants();
}),
]),
])
)
);
}
async function refreshGroupRoleGrants() {
const grants = await getJson("/admin/group-roles");
const tbody = document.querySelector("#group-role-grants-table tbody");
fillTable(
tbody,
grants.map((g) =>
el("tr", {}, [
el("td", { textContent: g.user_group_name }),
el("td", { textContent: g.host_group_name }),
el("td", { textContent: g.role_name }),
el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson("/admin/group-roles/revoke", "POST", {
user_group_id: g.user_group_id, host_group_id: g.host_group_id, role_name: g.role_name,
});
await refreshGroupRoleGrants();
}),
]),
])
)
);
}
document.getElementById("role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson("/admin/roles/grant", "POST", {
user_id: Number(document.getElementById("rg-user").value),
host_group_id: Number(document.getElementById("rg-hostgroup").value),
role_name: document.getElementById("rg-role").value,
expires_at: document.getElementById("rg-expires").value.trim() || null,
});
showBanner("Rolle vergeben.", "ok");
await refreshRoleGrants();
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await sendJson("/admin/group-roles/grant", "POST", {
user_group_id: Number(document.getElementById("grg-group").value),
host_group_id: Number(document.getElementById("grg-hostgroup").value),
role_name: document.getElementById("grg-role").value,
expires_at: document.getElementById("grg-expires").value.trim() || null,
});
showBanner("Rolle an Gruppe vergeben.", "ok");
await refreshGroupRoleGrants();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// API-Tokens
// ---------------------------------------------------------------------
let cachedScopes = [];
async function loadTokensTab() {
if (cachedScopes.length === 0) {
const result = await getJson("/admin/scopes");
cachedScopes = result.scopes;
const grid = document.getElementById("tc-scope-grid");
grid.replaceChildren(
...cachedScopes.map((scope) => {
const id = `scope-${scope.replace(":", "-")}`;
const row = el("div", { className: "checkbox-row" });
const checkbox = el("input", { type: "checkbox", id, value: scope });
const label = el("label", { for: id, textContent: scope });
row.appendChild(checkbox);
row.appendChild(label);
return row;
})
);
}
await refreshSharedSelects();
await refreshTokens();
}
async function refreshTokens() {
const tokens = await getJson("/admin/tokens");
const tbody = document.querySelector("#tokens-table tbody");
fillTable(
tbody,
tokens.map((t) => {
const status = t.revoked_at ? "widerrufen" : (t.expires_at && t.expires_at < new Date().toISOString() ? "abgelaufen" : "aktiv");
const scopesCell = el("td", {});
for (const s of t.scopes) {
scopesCell.appendChild(el("span", { className: "badge", textContent: s }));
}
const actionsCell = el("td", {});
if (!t.revoked_at) {
actionsCell.appendChild(
actionButton("Widerrufen", "btn-danger", async () => {
await sendJson(`/admin/tokens/${t.id}/revoke`, "POST", {});
await refreshTokens();
})
);
}
return el("tr", {}, [
el("td", { textContent: String(t.id) }),
el("td", { textContent: t.label }),
el("td", { textContent: t.username }),
el("td", { textContent: t.prefix }),
scopesCell,
el("td", { textContent: t.last_used_at || "nie" }),
el("td", { textContent: status }),
actionsCell,
]);
})
);
}
document.getElementById("token-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const scopes = Array.from(document.querySelectorAll("#tc-scope-grid input:checked")).map((c) => c.value);
if (scopes.length === 0) {
throw new Error("Mindestens einen Scope auswaehlen.");
}
const payload = {
label: document.getElementById("tc-label").value.trim(),
user_id: Number(document.getElementById("tc-user").value),
scopes,
expires_at: document.getElementById("tc-expires").value.trim() || null,
};
const result = await sendJson("/admin/tokens", "POST", payload);
const box = document.getElementById("token-reveal-box");
box.textContent = `Token (nur jetzt sichtbar, bitte sicher speichern): ${result.token}`;
box.classList.remove("hidden");
showBanner(`Token '${payload.label}' erstellt.`, "ok");
ev.target.reset();
document.querySelectorAll("#tc-scope-grid input:checked").forEach((c) => { c.checked = false; });
await refreshTokens();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Audit-Log
// ---------------------------------------------------------------------
async function loadAuditTab() {
await refreshAuditLog();
}
async function refreshAuditLog() {
const entries = await getJson("/admin/audit-log?limit=200");
const tbody = document.querySelector("#audit-table tbody");
fillTable(
tbody,
entries.map((e) =>
el("tr", {}, [
el("td", { textContent: String(e.id) }),
el("td", { textContent: e.ts }),
el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }),
el("td", { textContent: e.client_ip || "-" }),
el("td", { textContent: e.event_type }),
el("td", {}, [el("pre", { className: "json-view", textContent: e.details })]),
])
)
);
}
document.getElementById("audit-verify-btn").addEventListener("click", async () => {
try {
const result = await getJson("/admin/audit-log/verify");
const box = document.getElementById("audit-verify-result");
box.classList.remove("hidden");
box.className = `banner ${result.intact ? "ok" : "error"}`;
box.textContent = result.intact
? "Audit-Kette ist intakt."
: `Audit-Kette ist ab Eintrag ${result.first_broken_id} manipuliert!`;
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// Gemeinsame Selects (User/Hostgruppen/Gruppen) auf dem aktuellen Stand halten
// ---------------------------------------------------------------------
async function refreshSharedSelects() {
if (cachedUsers.length === 0) await refreshUsers();
if (cachedHostGroups.length === 0) await refreshHostGroups();
if (cachedGroups.length === 0) await refreshGroups();
const userOpts = () => optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`);
const hostGroupOpts = () => optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`);
const groupOpts = () => optionsFromList(cachedGroups, "id", (g) => `${g.name} (#${g.id})`);
const rgUser = document.getElementById("rg-user");
if (rgUser) rgUser.replaceChildren(...userOpts());
const rgHostgroup = document.getElementById("rg-hostgroup");
if (rgHostgroup) rgHostgroup.replaceChildren(...hostGroupOpts());
const grgGroup = document.getElementById("grg-group");
if (grgGroup) grgGroup.replaceChildren(...groupOpts());
const grgHostgroup = document.getElementById("grg-hostgroup");
if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts());
const tcUser = document.getElementById("tc-user");
if (tcUser) tcUser.replaceChildren(...userOpts());
}
// ---------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------
document.getElementById("logout-btn").addEventListener("click", async () => {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
window.location.href = "/";
});
async function main() {
const me = await getJson("/auth/me");
if (!me.is_admin) {
window.location.href = "/dashboard";
return;
}
document.getElementById("whoami").textContent = `${me.username} (Admin)`;
loadedTabs.add("users");
await loadUsersTab();
}
main().catch((err) => showBanner(err.message, "error"));
})();