1918 lines
76 KiB
JavaScript
1918 lines
76 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const ROLE_NAMES = [
|
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
|
"session_recording_view", "admin_hostgroup", "credentials_view", "credentials_manage",
|
|
];
|
|
|
|
const bannerBox = document.getElementById("banner-box");
|
|
let meInfo = { is_admin: false, tenant_admin_of: [] };
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
function roleCheckboxGrid(container) {
|
|
container.replaceChildren(
|
|
...ROLE_NAMES.map((name) => {
|
|
const id = `${container.id}-${name}`;
|
|
const row = el("div", { className: "checkbox-row" });
|
|
const checkbox = el("input", { type: "checkbox", id, value: name });
|
|
const label = el("label", { for: id, textContent: name });
|
|
row.appendChild(checkbox);
|
|
row.appendChild(label);
|
|
return row;
|
|
})
|
|
);
|
|
}
|
|
|
|
function checkedValues(container) {
|
|
return Array.from(container.querySelectorAll("input:checked")).map((c) => c.value);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Tabs
|
|
// ---------------------------------------------------------------------
|
|
|
|
const tabLoaders = {
|
|
users: loadUsersTab,
|
|
groups: loadGroupsTab,
|
|
hostgroups: loadHostsTab,
|
|
servers: loadHostsTab,
|
|
credentials: loadCredentialsTab,
|
|
roles: loadRolesTab,
|
|
tokens: loadTokensTab,
|
|
tenants: loadTenantsTab,
|
|
sessions: loadSessionsTab,
|
|
connlog: loadConnLogTab,
|
|
audit: loadAuditTab,
|
|
};
|
|
const loadedTabs = new Set();
|
|
let currentTab = null;
|
|
|
|
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 (currentTab === "connlog" && tab !== "connlog") disconnectLogStream();
|
|
currentTab = tab;
|
|
if (!loadedTabs.has(tab)) {
|
|
loadedTabs.add(tab);
|
|
tabLoaders[tab]().catch((err) => showBanner(err.message, "error"));
|
|
} else if (tab === "connlog") {
|
|
connectLogStream();
|
|
} else if (tab === "sessions") {
|
|
refreshSessions().catch((err) => showBanner(err.message, "error"));
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Mandanten-Auswahlfelder (fuer Super-Admins Dropdown aller Mandanten,
|
|
// fuer Mandanten-Admins nur die eigenen -- kein Server-Request noetig,
|
|
// steht bereits in meInfo.tenant_admin_of).
|
|
// ---------------------------------------------------------------------
|
|
|
|
let cachedTenants = [];
|
|
|
|
async function populateTenantSelect(select) {
|
|
if (meInfo.is_admin) {
|
|
if (cachedTenants.length === 0) {
|
|
const result = await getJson("/admin/tenants");
|
|
cachedTenants = result;
|
|
}
|
|
select.replaceChildren(...optionsFromList(cachedTenants, "id", (t) => `${t.name} (#${t.id})`));
|
|
} else {
|
|
select.replaceChildren(
|
|
...optionsFromList(meInfo.tenant_admin_of, "id", (t) => `${t.name} (#${t.id})`)
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Benutzer
|
|
// ---------------------------------------------------------------------
|
|
|
|
async function loadUsersTab() {
|
|
document.getElementById("uc-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
|
|
await refreshUsers();
|
|
}
|
|
|
|
let cachedUsers = [];
|
|
let editingUserId = null;
|
|
|
|
function tenantName(tenantId) {
|
|
if (tenantId === null || tenantId === undefined) return "-";
|
|
const t = cachedTenants.find((x) => x.id === tenantId);
|
|
return t ? t.name : `#${tenantId}`;
|
|
}
|
|
|
|
async function refreshUsers() {
|
|
if (meInfo.is_admin && cachedTenants.length === 0) {
|
|
cachedTenants = await getJson("/admin/tenants");
|
|
}
|
|
cachedUsers = await getJson("/admin/users");
|
|
const tbody = document.querySelector("#users-table tbody");
|
|
fillTable(
|
|
tbody,
|
|
cachedUsers.map((u) => {
|
|
const actions = el("td", {});
|
|
actions.appendChild(actionButton("Bearbeiten", "btn-secondary", () => showUserEdit(u)));
|
|
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();
|
|
})
|
|
);
|
|
}
|
|
actions.appendChild(
|
|
actionButton("Loeschen", "btn-danger", async () => {
|
|
const result = await apiFetch(`/admin/users/${u.id}`, { method: "DELETE" });
|
|
showBanner(
|
|
result.hard_deleted
|
|
? `Benutzer '${u.username}' vollstaendig geloescht.`
|
|
: `Benutzer '${u.username}' hat Audit-Historie und wurde deaktiviert/anonymisiert.`,
|
|
"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: tenantName(u.home_tenant_id) }),
|
|
el("td", { textContent: u.created_at }),
|
|
actions,
|
|
]);
|
|
})
|
|
);
|
|
}
|
|
|
|
function showUserEdit(u) {
|
|
editingUserId = u.id;
|
|
document.getElementById("user-edit-panel").classList.remove("hidden");
|
|
document.getElementById("user-edit-name").textContent = `${u.username} (#${u.id})`;
|
|
document.getElementById("ue-is-admin-row").classList.toggle("hidden", !meInfo.is_admin);
|
|
document.getElementById("ue-is-admin").checked = u.is_admin;
|
|
document.getElementById("ue-is-active").checked = u.is_active;
|
|
document.getElementById("ue-new-password").value = "";
|
|
}
|
|
|
|
document.getElementById("user-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const payload = { is_active: document.getElementById("ue-is-active").checked };
|
|
if (meInfo.is_admin) payload.is_admin = document.getElementById("ue-is-admin").checked;
|
|
const pw = document.getElementById("ue-new-password").value;
|
|
if (pw) payload.new_password = pw;
|
|
await sendJson(`/admin/users/${editingUserId}`, "PUT", payload);
|
|
showBanner("Benutzer aktualisiert.", "ok");
|
|
await refreshUsers();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
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 = meInfo.is_admin && 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;
|
|
let editingGroupId = null;
|
|
|
|
async function loadGroupsTab() {
|
|
document.getElementById("gc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
|
await populateTenantSelect(document.getElementById("gc-tenant"));
|
|
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: g.tenant_name }),
|
|
el("td", { textContent: String(g.member_count) }),
|
|
el("td", {}, [
|
|
actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)),
|
|
actionButton("Bearbeiten", "btn-secondary", () => showGroupEdit(g)),
|
|
actionButton("Loeschen", "btn-danger", async () => {
|
|
await apiFetch(`/admin/user-groups/${g.id}`, { method: "DELETE" });
|
|
showBanner(`Gruppe '${g.name}' geloescht.`, "ok");
|
|
await refreshGroups();
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
function showGroupEdit(g) {
|
|
editingGroupId = g.id;
|
|
document.getElementById("group-edit-panel").classList.remove("hidden");
|
|
document.getElementById("group-edit-name").textContent = g.name;
|
|
document.getElementById("ge-name").value = g.name;
|
|
document.getElementById("ge-description").value = g.description || "";
|
|
}
|
|
|
|
document.getElementById("group-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/user-groups/${editingGroupId}`, "PUT", {
|
|
name: document.getElementById("ge-name").value.trim(),
|
|
description: document.getElementById("ge-description").value.trim() || null,
|
|
});
|
|
showBanner("Gruppe aktualisiert.", "ok");
|
|
await refreshGroups();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
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;
|
|
const tenantSelect = document.getElementById("gc-tenant");
|
|
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
|
|
await sendJson("/admin/user-groups", "POST", { name, description, tenant_id });
|
|
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 cachedRdpCredentials = [];
|
|
let activeHostId = null;
|
|
let editingHostGroupId = null;
|
|
|
|
async function loadHostsTab() {
|
|
document.getElementById("hgc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
|
await populateTenantSelect(document.getElementById("hgc-tenant"));
|
|
await Promise.all([refreshHostGroups(), refreshSshKeysCache(), refreshRdpCredentialsCache()]);
|
|
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 || "" }),
|
|
el("td", { textContent: g.tenant_name }),
|
|
el("td", {}, [
|
|
actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)),
|
|
actionButton("Loeschen", "btn-danger", async () => {
|
|
await apiFetch(`/admin/host-groups/${g.id}`, { method: "DELETE" });
|
|
showBanner(`Hostgruppe '${g.name}' geloescht.`, "ok");
|
|
await refreshHostGroups();
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
const hcSelect = document.getElementById("hc-hostgroup");
|
|
hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`));
|
|
}
|
|
|
|
function showHostGroupEdit(g) {
|
|
editingHostGroupId = g.id;
|
|
document.getElementById("hostgroup-edit-panel").classList.remove("hidden");
|
|
document.getElementById("hostgroup-edit-name").textContent = g.name;
|
|
document.getElementById("hge-name").value = g.name;
|
|
document.getElementById("hge-description").value = g.description || "";
|
|
}
|
|
|
|
document.getElementById("hostgroup-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/host-groups/${editingHostGroupId}`, "PUT", {
|
|
name: document.getElementById("hge-name").value.trim(),
|
|
description: document.getElementById("hge-description").value.trim() || null,
|
|
});
|
|
showBanner("Hostgruppe aktualisiert.", "ok");
|
|
await refreshHostGroups();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
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", { textContent: h.tenant_name }),
|
|
el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]),
|
|
el("td", {}, [
|
|
actionButton("Details", "btn-secondary", () => showHostDetail(h.id)),
|
|
actionButton(h.is_active ? "Loeschen" : "Reaktivieren", h.is_active ? "btn-danger" : "btn-secondary", async () => {
|
|
if (h.is_active) {
|
|
await apiFetch(`/admin/hosts/${h.id}`, { method: "DELETE" });
|
|
showBanner(`Host '${h.hostname}' deaktiviert/geloescht.`, "ok");
|
|
} else {
|
|
await sendJson(`/admin/hosts/${h.id}`, "PUT", { is_active: true });
|
|
showBanner(`Host '${h.hostname}' reaktiviert.`, "ok");
|
|
}
|
|
await refreshHosts();
|
|
if (activeHostId === h.id) document.getElementById("host-detail-panel").classList.add("hidden");
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
async function refreshSshKeysCache() {
|
|
cachedSshKeys = await getJson("/admin/ssh-keys");
|
|
}
|
|
|
|
async function refreshRdpCredentialsCache() {
|
|
cachedRdpCredentials = await getJson("/admin/rdp-credentials");
|
|
}
|
|
|
|
function setDetailBanner(message, type) {
|
|
const box = document.getElementById("host-detail-banner");
|
|
if (!message) {
|
|
box.replaceChildren();
|
|
return;
|
|
}
|
|
box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message }));
|
|
}
|
|
|
|
function describeEffectiveUsername(host, isRdp) {
|
|
if (isRdp) {
|
|
if (host.rdp_credentials_username) {
|
|
const domain = host.rdp_credentials_domain ? `, Domaene ${host.rdp_credentials_domain}` : "";
|
|
return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`;
|
|
}
|
|
if (host.rdp_username) {
|
|
return `Noch kein Zugangsdaten-Objekt zugewiesen -- es wird der alte Wert am Host verwendet ("${host.rdp_username}"). Bitte unten RDP-Zugangsdaten zuweisen.`;
|
|
}
|
|
return "Kein Windows-Benutzername hinterlegt -- unten RDP-Zugangsdaten zuweisen (oder im Reiter \"Zugangsdaten\" zuerst anlegen), sonst schlaegt die Anmeldung fehl.";
|
|
}
|
|
const named = (host.ssh_keys || []).filter((k) => k.username);
|
|
if (named.length) {
|
|
return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`;
|
|
}
|
|
if ((host.ssh_keys || []).length === 0 && host.ssh_password_credentials_set) {
|
|
return `Anmeldung als "${host.ssh_password_credentials_username}" (SSH-Passwort, kein Schluessel zugeordnet).`;
|
|
}
|
|
if (host.ssh_username) {
|
|
return `Noch kein Benutzername am SSH-Key -- es wird der alte Wert am Host verwendet ("${host.ssh_username}"). Bitte im Reiter "Zugangsdaten" am Schluessel eintragen.`;
|
|
}
|
|
return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel oder als SSH-Passwort eintragen, sonst kommt keine Verbindung zustande.';
|
|
}
|
|
|
|
async function showHostDetail(hostId) {
|
|
setDetailBanner("", "ok");
|
|
try {
|
|
const host = await getJson(`/admin/hosts/${hostId}`);
|
|
activeHostId = host.id;
|
|
document.getElementById("host-detail-panel").classList.remove("hidden");
|
|
document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id}, ${host.tenant_name})`;
|
|
|
|
document.getElementById("he-hostname").value = host.hostname;
|
|
document.getElementById("he-address").value = host.address;
|
|
document.getElementById("he-port").value = String(host.port);
|
|
document.getElementById("he-clipboard").checked = host.clipboard_enabled;
|
|
document.getElementById("he-filetransfer").checked = host.file_transfer_enabled;
|
|
document.getElementById("he-nla").checked = host.rdp_require_nla;
|
|
document.getElementById("he-ignorecert").checked = host.rdp_ignore_cert;
|
|
document.getElementById("he-active").checked = host.is_active;
|
|
|
|
const isRdp = host.protocol === "rdp";
|
|
// Der Anmeldename steht bei den Zugangsdaten (SSH-Key bzw.
|
|
// RDP-Passwortsatz). Hier wird nur angezeigt, welcher Name greifen
|
|
// wuerde -- inklusive des Altbestands am Host, solange er noch benutzt
|
|
// wird.
|
|
document.getElementById("he-username-hint").textContent = describeEffectiveUsername(host, isRdp);
|
|
document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp);
|
|
document.getElementById("he-ignorecert-box").classList.toggle("hidden", !isRdp);
|
|
document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp);
|
|
document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp);
|
|
|
|
document.getElementById("host-key-result").classList.add("hidden");
|
|
if (host.ssh_host_key_fingerprint) {
|
|
const box = document.getElementById("host-key-result");
|
|
box.textContent = `Aktueller Fingerprint: ${host.ssh_host_key_fingerprint}`;
|
|
box.classList.remove("hidden");
|
|
}
|
|
|
|
const keysTbody = document.querySelector("#host-ssh-keys-table tbody");
|
|
fillTable(
|
|
keysTbody,
|
|
host.ssh_keys.map((k) =>
|
|
el("tr", {}, [
|
|
el("td", { textContent: String(k.id) }),
|
|
el("td", { textContent: k.label }),
|
|
el("td", { textContent: k.username || "(kein Benutzername)" }),
|
|
el("td", {}, [
|
|
actionButton("Entfernen", "btn-danger", async () => {
|
|
await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" });
|
|
await showHostDetail(host.id);
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
const keySelect = document.getElementById("hkm-key-select");
|
|
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
|
|
|
|
document.getElementById("ssh-pw-cred-status").textContent = host.ssh_password_credentials_set
|
|
? `SSH-Passwort gesetzt fuer "${host.ssh_password_credentials_username}" (zuletzt aktualisiert: ${host.ssh_password_credentials_updated_at}).`
|
|
: "Noch kein SSH-Passwort gesetzt.";
|
|
document.getElementById("hspw-username").value = host.ssh_password_credentials_username || "";
|
|
|
|
document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set
|
|
? `Zugewiesen: "${host.rdp_credentials_label}" -- Anmeldung als "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
|
|
: "Noch keine RDP-Zugangsdaten zugewiesen.";
|
|
const rdpSelect = document.getElementById("hrcm-select");
|
|
rdpSelect.replaceChildren(...optionsFromList(cachedRdpCredentials, "id", (c) => `${c.label} (${c.username}${c.domain ? "@" + c.domain : ""}, #${c.id})`));
|
|
if (host.rdp_credentials_id) rdpSelect.value = String(host.rdp_credentials_id);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
}
|
|
|
|
document.getElementById("host-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/hosts/${activeHostId}`, "PUT", {
|
|
hostname: document.getElementById("he-hostname").value.trim(),
|
|
address: document.getElementById("he-address").value.trim(),
|
|
port: Number(document.getElementById("he-port").value),
|
|
clipboard_enabled: document.getElementById("he-clipboard").checked,
|
|
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
|
|
rdp_require_nla: document.getElementById("he-nla").checked,
|
|
rdp_ignore_cert: document.getElementById("he-ignorecert").checked,
|
|
is_active: document.getElementById("he-active").checked,
|
|
});
|
|
showBanner("Host aktualisiert.", "ok");
|
|
await refreshHosts();
|
|
await showHostDetail(activeHostId);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("hc-protocol").addEventListener("change", (ev) => {
|
|
const isRdp = ev.target.value === "rdp";
|
|
document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp);
|
|
document.getElementById("hc-ignorecert-box").classList.toggle("hidden", !isRdp);
|
|
document.getElementById("hc-ignorecert-hint").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;
|
|
const tenantSelect = document.getElementById("hgc-tenant");
|
|
const tenant_id = tenantSelect.value ? Number(tenantSelect.value) : null;
|
|
await sendJson("/admin/host-groups", "POST", { name, description, tenant_id });
|
|
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,
|
|
rdp_require_nla: document.getElementById("hc-nla").checked,
|
|
rdp_ignore_cert: document.getElementById("hc-ignorecert").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) {
|
|
setDetailBanner(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");
|
|
await showHostDetail(activeHostId);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("host-rdp-cred-map-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const credentialId = document.getElementById("hrcm-select").value;
|
|
if (!credentialId) {
|
|
showBanner('Bitte zuerst im Reiter "Zugangsdaten" ein RDP-Zugangsdaten-Objekt anlegen.', "error");
|
|
return;
|
|
}
|
|
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials/${credentialId}`, "POST", {});
|
|
showBanner("RDP-Zugangsdaten zugewiesen.", "ok");
|
|
await showHostDetail(activeHostId);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("hrc-unassign-btn").addEventListener("click", async () => {
|
|
try {
|
|
await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" });
|
|
showBanner("Zuordnung entfernt.", "ok");
|
|
await showHostDetail(activeHostId);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("host-ssh-pw-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const username = document.getElementById("hspw-username").value.trim();
|
|
const password = document.getElementById("hspw-password").value;
|
|
await sendJson(`/admin/hosts/${activeHostId}/ssh-password`, "PUT", { username, password });
|
|
showBanner("SSH-Passwort gespeichert.", "ok");
|
|
document.getElementById("hspw-password").value = "";
|
|
await showHostDetail(activeHostId);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("hspw-delete-btn").addEventListener("click", async () => {
|
|
try {
|
|
await apiFetch(`/admin/hosts/${activeHostId}/ssh-password`, { method: "DELETE" });
|
|
showBanner("SSH-Passwort geloescht.", "ok");
|
|
await showHostDetail(activeHostId);
|
|
} catch (err) {
|
|
setDetailBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Zugangsdaten (SSH-Keys + RDP-Passwoerter)
|
|
// ---------------------------------------------------------------------
|
|
|
|
let editingSshKeyId = null;
|
|
|
|
async function loadCredentialsTab() {
|
|
document.getElementById("skc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
|
document.getElementById("rcc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
|
await Promise.all([
|
|
populateTenantSelect(document.getElementById("skc-tenant")),
|
|
populateTenantSelect(document.getElementById("rcc-tenant")),
|
|
]);
|
|
await Promise.all([refreshSshKeys(), refreshRdpCredentials()]);
|
|
}
|
|
|
|
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.username || "-" }),
|
|
el("td", { textContent: k.key_type }),
|
|
el("td", { textContent: k.tenant_name }),
|
|
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
|
|
el("td", { textContent: k.created_at }),
|
|
el("td", { textContent: k.rotated_at || "-" }),
|
|
el("td", {}, [
|
|
actionButton("Bearbeiten", "btn-secondary", () => showSshKeyEdit(k)),
|
|
actionButton("Loeschen", "btn-danger", async () => {
|
|
const result = await apiFetch(`/admin/ssh-keys/${k.id}`, { method: "DELETE" });
|
|
const suffix = result.unmapped_host_ids.length
|
|
? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)`
|
|
: "";
|
|
showBanner(`SSH-Key '${k.label}' geloescht.${suffix}`, "ok");
|
|
await refreshSshKeys();
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
function showSshKeyEdit(k) {
|
|
editingSshKeyId = k.id;
|
|
document.getElementById("ssh-key-edit-panel").classList.remove("hidden");
|
|
document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`;
|
|
document.getElementById("ske-label").value = k.label;
|
|
document.getElementById("ske-username").value = k.username || "";
|
|
document.getElementById("skr-type").value = k.key_type;
|
|
document.getElementById("skp-passphrase").value = "";
|
|
document.getElementById("ske-passphrase-state").textContent = k.has_passphrase
|
|
? "Fuer diesen Schluessel ist eine Passphrase hinterlegt."
|
|
: "Fuer diesen Schluessel ist KEINE Passphrase hinterlegt. Ist der Schluessel verschluesselt, schlaegt jeder Verbindungsversuch fehl.";
|
|
}
|
|
|
|
async function saveSshKeyPassphrase(value) {
|
|
// Wird gegen das bereits gespeicherte Schluesselmaterial geprueft
|
|
// (update_ssh_key in app/admin/routes.py); passt sie nicht, antwortet der
|
|
// Server mit 400 und es wird nichts gespeichert.
|
|
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { passphrase: value });
|
|
await refreshSshKeys();
|
|
const updated = cachedSshKeys.find((k) => k.id === editingSshKeyId);
|
|
if (updated) showSshKeyEdit(updated);
|
|
}
|
|
|
|
document.getElementById("ssh-key-passphrase-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const value = document.getElementById("skp-passphrase").value;
|
|
if (!value) {
|
|
showBanner("Bitte eine Passphrase eingeben oder 'Passphrase entfernen' verwenden.", "error");
|
|
return;
|
|
}
|
|
await saveSshKeyPassphrase(value);
|
|
showBanner("Passphrase gespeichert und gegen den Schluessel geprueft.", "ok");
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("skp-clear-btn").addEventListener("click", async () => {
|
|
try {
|
|
await saveSshKeyPassphrase(null);
|
|
showBanner("Passphrase entfernt.", "ok");
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
|
|
label: document.getElementById("ske-label").value.trim() || null,
|
|
// Leeres Feld -> null entfernt den Benutzernamen bewusst (die
|
|
// Server-Semantik unterscheidet zwischen "Feld fehlt" und "null").
|
|
username: document.getElementById("ske-username").value.trim() || null,
|
|
});
|
|
showBanner("SSH-Key aktualisiert.", "ok");
|
|
await refreshSshKeys();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("ssh-key-rotate-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
|
|
key_type: document.getElementById("skr-type").value,
|
|
private_key_pem: document.getElementById("skr-private").value,
|
|
public_key: document.getElementById("skr-public").value,
|
|
passphrase: document.getElementById("skr-passphrase").value || null,
|
|
});
|
|
showBanner("Neues Schluesselmaterial gespeichert.", "ok");
|
|
ev.target.reset();
|
|
await refreshSshKeys();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("skc-generate-btn").addEventListener("click", async () => {
|
|
try {
|
|
const keyType = document.getElementById("skc-type").value;
|
|
if (keyType === "ca-cert") {
|
|
showBanner("Automatische Erzeugung ist fuer ca-cert nicht moeglich -- bitte Schluessel/Zertifikat der eigenen CA einfuegen.", "error");
|
|
return;
|
|
}
|
|
const result = await sendJson("/admin/ssh-keys/generate", "POST", { key_type: keyType });
|
|
document.getElementById("skc-private").value = result.private_key_pem;
|
|
document.getElementById("skc-public").value = result.public_key;
|
|
showBanner("Schluesselpaar erzeugt -- bitte Label (und ggf. Benutzername) ausfuellen und 'Anlegen' druecken, um es zu speichern.", "ok");
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const tenantSelect = document.getElementById("skc-tenant");
|
|
const payload = {
|
|
label: document.getElementById("skc-label").value.trim(),
|
|
username: document.getElementById("skc-username").value.trim() || null,
|
|
key_type: document.getElementById("skc-type").value,
|
|
private_key_pem: document.getElementById("skc-private").value,
|
|
public_key: document.getElementById("skc-public").value,
|
|
// Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt.
|
|
passphrase: document.getElementById("skc-passphrase").value || null,
|
|
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
|
|
};
|
|
await sendJson("/admin/ssh-keys", "POST", payload);
|
|
showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok");
|
|
ev.target.reset();
|
|
await refreshSshKeys();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
let editingRdpCredentialId = null;
|
|
|
|
async function refreshRdpCredentials() {
|
|
cachedRdpCredentials = await getJson("/admin/rdp-credentials");
|
|
const tbody = document.querySelector("#rdp-creds-table tbody");
|
|
fillTable(
|
|
tbody,
|
|
cachedRdpCredentials.map((r) =>
|
|
el("tr", {}, [
|
|
el("td", { textContent: String(r.id) }),
|
|
el("td", { textContent: r.label }),
|
|
el("td", { textContent: r.username || "-" }),
|
|
el("td", { textContent: r.domain || "-" }),
|
|
el("td", { textContent: r.tenant_name }),
|
|
el("td", {
|
|
textContent: r.assigned_hosts.length
|
|
? r.assigned_hosts.map((h) => h.hostname).join(", ")
|
|
: "(keinem Server zugewiesen)",
|
|
}),
|
|
el("td", { textContent: r.rotated_at || r.created_at }),
|
|
el("td", {}, [
|
|
actionButton("Bearbeiten", "btn-secondary", () => showRdpCredentialEdit(r)),
|
|
actionButton("Loeschen", "btn-danger", async () => {
|
|
const result = await apiFetch(`/admin/rdp-credentials/${r.id}`, { method: "DELETE" });
|
|
const suffix = result.unmapped_host_ids.length
|
|
? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)`
|
|
: "";
|
|
showBanner(`RDP-Zugangsdaten '${r.label}' geloescht.${suffix}`, "ok");
|
|
await refreshRdpCredentials();
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
function showRdpCredentialEdit(r) {
|
|
editingRdpCredentialId = r.id;
|
|
document.getElementById("rdp-cred-edit-panel").classList.remove("hidden");
|
|
document.getElementById("rdp-cred-edit-name").textContent = `${r.label} (#${r.id})`;
|
|
document.getElementById("rce-label").value = r.label;
|
|
document.getElementById("rce-username").value = r.username || "";
|
|
document.getElementById("rce-domain").value = r.domain || "";
|
|
document.getElementById("rcr-password").value = "";
|
|
}
|
|
|
|
document.getElementById("rdp-cred-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/rdp-credentials/${editingRdpCredentialId}`, "PUT", {
|
|
label: document.getElementById("rce-label").value.trim() || null,
|
|
username: document.getElementById("rce-username").value.trim() || null,
|
|
// Leeres Feld -> null entfernt die Domaene bewusst (Feld-vorhanden-
|
|
// vs-null-Semantik wie bei SSH-Keys).
|
|
domain: document.getElementById("rce-domain").value.trim() || null,
|
|
});
|
|
showBanner("RDP-Zugangsdaten aktualisiert.", "ok");
|
|
await refreshRdpCredentials();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("rdp-cred-rotate-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const password = document.getElementById("rcr-password").value;
|
|
await sendJson(`/admin/rdp-credentials/${editingRdpCredentialId}`, "PUT", { password });
|
|
showBanner("Neues Passwort gespeichert.", "ok");
|
|
document.getElementById("rcr-password").value = "";
|
|
await refreshRdpCredentials();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("rdp-cred-create-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const tenantSelect = document.getElementById("rcc-tenant");
|
|
const payload = {
|
|
label: document.getElementById("rcc-label").value.trim(),
|
|
username: document.getElementById("rcc-username").value.trim(),
|
|
domain: document.getElementById("rcc-domain").value.trim() || null,
|
|
password: document.getElementById("rcc-password").value,
|
|
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
|
|
};
|
|
await sendJson("/admin/rdp-credentials", "POST", payload);
|
|
showBanner(`RDP-Zugangsdaten '${payload.label}' angelegt.`, "ok");
|
|
ev.target.reset();
|
|
await refreshRdpCredentials();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Rollen
|
|
// ---------------------------------------------------------------------
|
|
|
|
async function loadRolesTab() {
|
|
roleCheckboxGrid(document.getElementById("rg-role-grid"));
|
|
roleCheckboxGrid(document.getElementById("grg-role-grid"));
|
|
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 {
|
|
const role_names = checkedValues(document.getElementById("rg-role-grid"));
|
|
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
|
|
await sendJson("/admin/roles/grant", "POST", {
|
|
user_id: Number(document.getElementById("rg-user").value),
|
|
host_group_id: Number(document.getElementById("rg-hostgroup").value),
|
|
role_names,
|
|
expires_at: document.getElementById("rg-expires").value.trim() || null,
|
|
});
|
|
showBanner(`Rolle(n) vergeben: ${role_names.join(", ")}.`, "ok");
|
|
document.querySelectorAll("#rg-role-grid input:checked").forEach((c) => { c.checked = false; });
|
|
await refreshRoleGrants();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const role_names = checkedValues(document.getElementById("grg-role-grid"));
|
|
if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen.");
|
|
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_names,
|
|
expires_at: document.getElementById("grg-expires").value.trim() || null,
|
|
});
|
|
showBanner(`Rolle(n) an Gruppe vergeben: ${role_names.join(", ")}.`, "ok");
|
|
document.querySelectorAll("#grg-role-grid input:checked").forEach((c) => { c.checked = false; });
|
|
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;
|
|
})
|
|
);
|
|
}
|
|
document.getElementById("tc-tenant-box").classList.toggle("hidden", !meInfo.is_admin && meInfo.tenant_admin_of.length <= 1);
|
|
await populateTenantSelect(document.getElementById("tc-tenant"));
|
|
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 }),
|
|
el("td", { textContent: t.tenant_name }),
|
|
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 tenantSelect = document.getElementById("tc-tenant");
|
|
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,
|
|
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : 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");
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Mandanten (nur Super-Admin -- Tab-Button ist fuer alle anderen hidden)
|
|
// ---------------------------------------------------------------------
|
|
|
|
let editingTenantId = null;
|
|
let activeTenantAdminsId = null;
|
|
|
|
async function loadTenantsTab() {
|
|
await refreshTenants();
|
|
}
|
|
|
|
async function refreshTenants() {
|
|
cachedTenants = await getJson("/admin/tenants");
|
|
const tbody = document.querySelector("#tenants-table tbody");
|
|
fillTable(
|
|
tbody,
|
|
cachedTenants.map((t) =>
|
|
el("tr", {}, [
|
|
el("td", { textContent: String(t.id) }),
|
|
el("td", { textContent: t.name }),
|
|
el("td", { textContent: t.description || "" }),
|
|
el("td", { textContent: t.is_active ? "ja" : "nein" }),
|
|
el("td", { textContent: String(t.host_group_count) }),
|
|
el("td", { textContent: String(t.user_group_count) }),
|
|
el("td", {}, [
|
|
actionButton("Admins", "btn-secondary", () => showTenantAdmins(t)),
|
|
actionButton("Bearbeiten", "btn-secondary", () => showTenantEdit(t)),
|
|
actionButton("Loeschen", "btn-danger", async () => {
|
|
await apiFetch(`/admin/tenants/${t.id}`, { method: "DELETE" });
|
|
showBanner(`Mandant '${t.name}' geloescht.`, "ok");
|
|
await refreshTenants();
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
function showTenantEdit(t) {
|
|
editingTenantId = t.id;
|
|
document.getElementById("tenant-edit-panel").classList.remove("hidden");
|
|
document.getElementById("tenant-edit-name").textContent = t.name;
|
|
document.getElementById("tne-name").value = t.name;
|
|
document.getElementById("tne-description").value = t.description || "";
|
|
document.getElementById("tne-active").checked = t.is_active;
|
|
}
|
|
|
|
document.getElementById("tenant-edit-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
await sendJson(`/admin/tenants/${editingTenantId}`, "PUT", {
|
|
name: document.getElementById("tne-name").value.trim(),
|
|
description: document.getElementById("tne-description").value.trim() || null,
|
|
is_active: document.getElementById("tne-active").checked,
|
|
});
|
|
showBanner("Mandant aktualisiert.", "ok");
|
|
await refreshTenants();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
async function showTenantAdmins(t) {
|
|
activeTenantAdminsId = t.id;
|
|
document.getElementById("tenant-admins-panel").classList.remove("hidden");
|
|
document.getElementById("tenant-admins-name").textContent = t.name;
|
|
await refreshTenantAdmins();
|
|
const select = document.getElementById("ta-user-select");
|
|
if (cachedUsers.length === 0) await refreshUsers();
|
|
select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`));
|
|
}
|
|
|
|
async function refreshTenantAdmins() {
|
|
const admins = await getJson(`/admin/tenants/${activeTenantAdminsId}/admins`);
|
|
const tbody = document.querySelector("#tenant-admins-table tbody");
|
|
fillTable(
|
|
tbody,
|
|
admins.map((a) =>
|
|
el("tr", {}, [
|
|
el("td", { textContent: String(a.user_id) }),
|
|
el("td", { textContent: a.username }),
|
|
el("td", { textContent: a.granted_at }),
|
|
el("td", {}, [
|
|
actionButton("Entfernen", "btn-danger", async () => {
|
|
await apiFetch(`/admin/tenants/${activeTenantAdminsId}/admins/${a.user_id}`, { method: "DELETE" });
|
|
await refreshTenantAdmins();
|
|
}),
|
|
]),
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
document.getElementById("tenant-create-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const name = document.getElementById("tnc-name").value.trim();
|
|
const description = document.getElementById("tnc-description").value.trim() || null;
|
|
await sendJson("/admin/tenants", "POST", { name, description });
|
|
showBanner(`Mandant '${name}' angelegt.`, "ok");
|
|
ev.target.reset();
|
|
await refreshTenants();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
document.getElementById("tenant-admin-add-form").addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const userId = Number(document.getElementById("ta-user-select").value);
|
|
await sendJson(`/admin/tenants/${activeTenantAdminsId}/admins`, "POST", { user_id: userId });
|
|
showBanner("Mandanten-Admin hinzugefuegt.", "ok");
|
|
await refreshTenantAdmins();
|
|
} catch (err) {
|
|
showBanner(err.message, "error");
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden,
|
|
// Link zur Aufzeichnung.
|
|
// ---------------------------------------------------------------------
|
|
|
|
let sessionsActiveOnly = true;
|
|
let sessionsAutoTimer = null;
|
|
|
|
async function loadSessionsTab() {
|
|
document.getElementById("sessions-active-only").checked = sessionsActiveOnly;
|
|
await refreshSessions();
|
|
if (sessionsAutoTimer === null) {
|
|
sessionsAutoTimer = window.setInterval(() => {
|
|
if (currentTab === "sessions") refreshSessions().catch(() => {});
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
document.getElementById("sessions-active-only").addEventListener("change", (ev) => {
|
|
sessionsActiveOnly = ev.target.checked;
|
|
refreshSessions().catch((err) => showBanner(err.message, "error"));
|
|
});
|
|
|
|
async function refreshSessions() {
|
|
const rows = await getJson(`/admin/sessions?active_only=${sessionsActiveOnly}&limit=300`);
|
|
const tbody = document.querySelector("#sessions-table tbody");
|
|
fillTable(
|
|
tbody,
|
|
rows.map((s) => {
|
|
const actions = el("td", {});
|
|
if (s.is_active && s.killable) {
|
|
actions.appendChild(
|
|
actionButton("Beenden", "btn-danger", async () => {
|
|
await sendJson(`/admin/sessions/${s.id}/terminate`, "POST", {});
|
|
showBanner(`Sitzung #${s.id} beendet.`, "ok");
|
|
await refreshSessions();
|
|
})
|
|
);
|
|
} else if (s.is_active) {
|
|
actions.appendChild(el("span", { className: "hint", textContent: "anderer Prozess" }));
|
|
}
|
|
if (s.watchable) {
|
|
actions.appendChild(
|
|
actionButton("Live ansehen", "btn-secondary", () => watchSession(s))
|
|
);
|
|
}
|
|
if (s.has_recording) {
|
|
actions.appendChild(
|
|
actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s))
|
|
);
|
|
}
|
|
return el("tr", {}, [
|
|
el("td", { textContent: String(s.id) }),
|
|
el("td", { textContent: s.username }),
|
|
el("td", { textContent: `${s.hostname} (${s.host_group_name})` }),
|
|
el("td", { textContent: s.protocol.toUpperCase() }),
|
|
el("td", { textContent: s.started_at }),
|
|
el("td", {}, [
|
|
el("span", {
|
|
className: `badge ${s.is_active ? "ok" : ""}`,
|
|
textContent: s.is_active ? "aktiv" : (s.end_reason || "beendet"),
|
|
}),
|
|
]),
|
|
el("td", { textContent: s.client_ip }),
|
|
actions,
|
|
]);
|
|
})
|
|
);
|
|
}
|
|
|
|
// --- Live-Mitschau einer aktiven SSH-Sitzung (nur lesend) --------------
|
|
//
|
|
// Verbindet sich mit GET /ws/sessions/{id}/watch (app/ssh_proxy/
|
|
// terminal_ws.py) und schreibt jedes "output"-Frame in ein eigenes,
|
|
// schreibgeschuetztes xterm.js-Terminal -- exakt dieselben Frames, die
|
|
// auch der Sitzungsinhaber selbst bekommt (active_sessions.broadcast()).
|
|
// Es gibt hier bewusst KEINEN Eingabepfad: ein Beobachter kann die
|
|
// fremde Sitzung nicht beeinflussen.
|
|
|
|
let activeWatch = null;
|
|
|
|
function closeWatchOverlay() {
|
|
document.getElementById("watch-overlay").classList.add("hidden");
|
|
if (activeWatch) {
|
|
activeWatch.destroy();
|
|
activeWatch = null;
|
|
}
|
|
document.getElementById("watch-stage").replaceChildren();
|
|
}
|
|
|
|
document.getElementById("watch-close-btn").addEventListener("click", closeWatchOverlay);
|
|
// Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst)
|
|
// schliesst die Live-Ansicht ebenfalls.
|
|
document.getElementById("watch-overlay").addEventListener("click", (ev) => {
|
|
if (ev.target === ev.currentTarget) closeWatchOverlay();
|
|
});
|
|
|
|
function watchSession(session) {
|
|
closeWatchOverlay();
|
|
const overlay = document.getElementById("watch-overlay");
|
|
const title = document.getElementById("watch-title");
|
|
const statusEl = document.getElementById("watch-status");
|
|
const stage = document.getElementById("watch-stage");
|
|
overlay.classList.remove("hidden");
|
|
title.textContent = `Live-Ansicht -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`;
|
|
statusEl.textContent = "verbinde ...";
|
|
|
|
const container = el("div", { className: "rec-terminal" });
|
|
stage.appendChild(container);
|
|
const term = new Terminal({
|
|
cursorBlink: false, disableStdin: true, convertEol: false,
|
|
fontFamily: "Menlo, Consolas, monospace", fontSize: 13,
|
|
theme: { background: "#000000" },
|
|
});
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.open(container);
|
|
fitAddon.fit();
|
|
|
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const ws = new WebSocket(`${proto}//${window.location.host}/ws/sessions/${session.id}/watch`);
|
|
|
|
ws.addEventListener("open", () => { statusEl.textContent = "verbunden (nur lesend)"; });
|
|
ws.addEventListener("close", (ev) => {
|
|
statusEl.textContent = ev.reason
|
|
? `getrennt: ${ev.reason}`
|
|
: "getrennt (Sitzung vermutlich beendet)";
|
|
});
|
|
ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; });
|
|
ws.addEventListener("message", (ev) => {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === "output") {
|
|
term.write(recB64decode(msg.data));
|
|
}
|
|
});
|
|
|
|
activeWatch = {
|
|
destroy() {
|
|
try { ws.close(); } catch (_) { /* bereits getrennt */ }
|
|
term.dispose();
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Wiedergabe (SSH: Terminal-Replay ueber xterm.js; RDP: volle
|
|
// grafische Wiedergabe ueber Guacamole.SessionRecording) --------------
|
|
//
|
|
// Die vom Recorder aufgezeichneten "output"-Instruktionen einer RDP-
|
|
// Sitzung sind bereits ein gueltiger .guac-Datenstrom (siehe
|
|
// app/recordings/recorder.py + app/rdp_proxy/guacd_client.py::
|
|
// encode_instruction): guacd sendet als Teil des normalen Protokolls
|
|
// periodische "sync"-Instruktionen mit Zeitstempel, genau das nutzt
|
|
// Guacamole.SessionRecording zur Framebildung. Aneinandergehaengt ergeben
|
|
// die "output"-Eintraege deshalb exakt das Format, das die Bibliothek
|
|
// erwartet -- kein eigener Player noetig.
|
|
|
|
// Bugfix ("SSH-Wiedergabe funktioniert nicht"): NICHT als JS-String ueber
|
|
// decodeURIComponent(escape(atob(...))) dekodieren. Jeder aufgezeichnete
|
|
// "output"-Eintrag entspricht genau einem process.stdout.read()-Chunk auf
|
|
// dem Server (app/ssh_proxy/terminal_ws.py) -- ein Mehrbyte-UTF-8-Zeichen
|
|
// (Umlaute/Sonderzeichen in Banner, Prompt, htop, ls --color etc.) kann
|
|
// dabei GENAU an der Chunk-Grenze zerschnitten sein. decodeURIComponent()
|
|
// wirft dann "URI malformed" fuer diesen Eintrag -- und weil term.write()
|
|
// in scheduleNext()/seekTo() innerhalb eines try-losen setTimeout-Callbacks
|
|
// steht, bricht genau dort die gesamte Wiedergabe-Kette endgueltig ab
|
|
// (idx wird nie mehr erhoeht, scheduleNext() nie mehr aufgerufen): die
|
|
// Wiedergabe blieb an der ersten betroffenen Stelle fuer immer haengen,
|
|
// oft schon beim Login-Banner. term.write() nimmt daher direkt die rohen
|
|
// Bytes als Uint8Array entgegen -- xterm.js ist ein echter
|
|
// Terminal-Emulator mit eigenem, chunk-uebergreifend korrektem
|
|
// UTF-8-Parser, genau fuer diesen Fall gebaut (siehe auch
|
|
// static/js/terminal.js::b64decodeBytes fuer dieselbe Klasse Bug in der
|
|
// Live-Ansicht).
|
|
function recB64decode(b64) {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
function formatMs(ms) {
|
|
const totalSec = Math.max(0, Math.round(ms / 1000));
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
let activePlayer = null;
|
|
|
|
function closeRecordingOverlay() {
|
|
document.getElementById("rec-overlay").classList.add("hidden");
|
|
if (activePlayer) {
|
|
activePlayer.destroy();
|
|
activePlayer = null;
|
|
}
|
|
document.getElementById("rec-stage").replaceChildren();
|
|
}
|
|
|
|
document.getElementById("rec-close-btn").addEventListener("click", closeRecordingOverlay);
|
|
// Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst)
|
|
// schliesst die Wiedergabe ebenfalls.
|
|
document.getElementById("rec-overlay").addEventListener("click", (ev) => {
|
|
if (ev.target === ev.currentTarget) closeRecordingOverlay();
|
|
});
|
|
|
|
function createSshPlayer(entries) {
|
|
const outputEntries = entries.filter((e) => e.dir === "output");
|
|
const stage = document.getElementById("rec-stage");
|
|
const container = el("div", { className: "rec-terminal" });
|
|
stage.appendChild(container);
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: false, disableStdin: true, convertEol: false,
|
|
fontFamily: "Menlo, Consolas, monospace", fontSize: 13,
|
|
theme: { background: "#000000" },
|
|
});
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.open(container);
|
|
fitAddon.fit();
|
|
|
|
const playBtn = document.getElementById("rec-playpause-btn");
|
|
const seek = document.getElementById("rec-seek");
|
|
const timeLabel = document.getElementById("rec-time");
|
|
const speedSelect = document.getElementById("rec-speed");
|
|
speedSelect.classList.remove("hidden");
|
|
|
|
const durationMs = outputEntries.length ? outputEntries[outputEntries.length - 1].t * 1000 : 0;
|
|
seek.min = "0";
|
|
seek.max = String(Math.round(durationMs));
|
|
seek.value = "0";
|
|
|
|
let idx = 0;
|
|
let playing = false;
|
|
let timer = null;
|
|
let speed = Number(speedSelect.value) || 1;
|
|
let realStart = 0;
|
|
let baseMs = 0;
|
|
|
|
function updateTimeUi(posMs) {
|
|
seek.value = String(Math.round(posMs));
|
|
timeLabel.textContent = `${formatMs(posMs)} / ${formatMs(durationMs)}`;
|
|
}
|
|
|
|
function scheduleNext() {
|
|
if (!playing) return;
|
|
if (idx >= outputEntries.length) {
|
|
playing = false;
|
|
playBtn.textContent = "Abspielen";
|
|
return;
|
|
}
|
|
const entry = outputEntries[idx];
|
|
const targetMs = entry.t * 1000;
|
|
const elapsedMs = baseMs + (Date.now() - realStart) * speed;
|
|
const delay = Math.max(0, (targetMs - elapsedMs) / speed);
|
|
timer = window.setTimeout(() => {
|
|
term.write(recB64decode(entry.data));
|
|
idx += 1;
|
|
updateTimeUi(targetMs);
|
|
scheduleNext();
|
|
}, delay);
|
|
}
|
|
|
|
function play() {
|
|
if (playing || idx >= outputEntries.length) return;
|
|
playing = true;
|
|
playBtn.textContent = "Pause";
|
|
realStart = Date.now();
|
|
baseMs = idx > 0 ? outputEntries[idx - 1].t * 1000 : 0;
|
|
scheduleNext();
|
|
}
|
|
|
|
function pause() {
|
|
playing = false;
|
|
playBtn.textContent = "Abspielen";
|
|
if (timer) { window.clearTimeout(timer); timer = null; }
|
|
}
|
|
|
|
function seekTo(targetMs) {
|
|
pause();
|
|
term.reset();
|
|
idx = 0;
|
|
const targetSec = targetMs / 1000;
|
|
while (idx < outputEntries.length && outputEntries[idx].t <= targetSec) {
|
|
term.write(recB64decode(outputEntries[idx].data));
|
|
idx += 1;
|
|
}
|
|
updateTimeUi(targetMs);
|
|
}
|
|
|
|
const onPlayPause = () => { if (playing) pause(); else play(); };
|
|
const onSeekChange = () => seekTo(Number(seek.value));
|
|
const onSpeedChange = () => {
|
|
speed = Number(speedSelect.value) || 1;
|
|
if (playing) { pause(); play(); }
|
|
};
|
|
|
|
playBtn.textContent = "Abspielen";
|
|
playBtn.addEventListener("click", onPlayPause);
|
|
seek.addEventListener("change", onSeekChange);
|
|
speedSelect.addEventListener("change", onSpeedChange);
|
|
updateTimeUi(0);
|
|
|
|
return {
|
|
destroy() {
|
|
pause();
|
|
playBtn.removeEventListener("click", onPlayPause);
|
|
seek.removeEventListener("change", onSeekChange);
|
|
speedSelect.removeEventListener("change", onSpeedChange);
|
|
speedSelect.classList.add("hidden");
|
|
term.dispose();
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRdpPlayer(entries) {
|
|
const stage = document.getElementById("rec-stage");
|
|
const container = el("div", {});
|
|
stage.appendChild(container);
|
|
|
|
const guacStream = entries.filter((e) => e.dir === "output").map((e) => e.data).join("");
|
|
const blob = new Blob([guacStream], { type: "application/octet-stream" });
|
|
const recording = new Guacamole.SessionRecording(blob);
|
|
container.appendChild(recording.getDisplay().getElement());
|
|
|
|
const playBtn = document.getElementById("rec-playpause-btn");
|
|
const seek = document.getElementById("rec-seek");
|
|
const timeLabel = document.getElementById("rec-time");
|
|
|
|
let posTimer = null;
|
|
let seeking = false;
|
|
|
|
function updateTimeUi() {
|
|
const duration = recording.getDuration();
|
|
const durationRounded = Math.round(duration);
|
|
if (Number(seek.max) !== durationRounded) seek.max = String(durationRounded);
|
|
if (!seeking) seek.value = String(Math.round(recording.getPosition()));
|
|
timeLabel.textContent = `${formatMs(recording.getPosition())} / ${formatMs(duration)}`;
|
|
}
|
|
|
|
recording.onload = updateTimeUi;
|
|
recording.onprogress = updateTimeUi;
|
|
recording.onplay = () => {
|
|
playBtn.textContent = "Pause";
|
|
if (posTimer) window.clearInterval(posTimer);
|
|
posTimer = window.setInterval(updateTimeUi, 200);
|
|
};
|
|
recording.onpause = () => {
|
|
playBtn.textContent = "Abspielen";
|
|
if (posTimer) { window.clearInterval(posTimer); posTimer = null; }
|
|
updateTimeUi();
|
|
};
|
|
recording.onerror = (message) => {
|
|
showBanner("Fehler bei der RDP-Wiedergabe: " + message, "error");
|
|
};
|
|
|
|
const onPlayPause = () => { if (recording.isPlaying()) recording.pause(); else recording.play(); };
|
|
const onSeekStart = () => { seeking = true; };
|
|
const onSeekInput = () => {
|
|
timeLabel.textContent = `${formatMs(Number(seek.value))} / ${formatMs(recording.getDuration())}`;
|
|
};
|
|
const onSeekCommit = () => {
|
|
recording.seek(Number(seek.value), () => { seeking = false; updateTimeUi(); });
|
|
};
|
|
|
|
playBtn.textContent = "Abspielen";
|
|
playBtn.addEventListener("click", onPlayPause);
|
|
seek.addEventListener("mousedown", onSeekStart);
|
|
seek.addEventListener("touchstart", onSeekStart);
|
|
seek.addEventListener("input", onSeekInput);
|
|
seek.addEventListener("change", onSeekCommit);
|
|
updateTimeUi();
|
|
|
|
return {
|
|
destroy() {
|
|
recording.pause();
|
|
recording.abort();
|
|
if (posTimer) window.clearInterval(posTimer);
|
|
playBtn.removeEventListener("click", onPlayPause);
|
|
seek.removeEventListener("mousedown", onSeekStart);
|
|
seek.removeEventListener("touchstart", onSeekStart);
|
|
seek.removeEventListener("input", onSeekInput);
|
|
seek.removeEventListener("change", onSeekCommit);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function showSessionRecording(session) {
|
|
const overlay = document.getElementById("rec-overlay");
|
|
const title = document.getElementById("rec-title");
|
|
const loading = document.getElementById("rec-loading");
|
|
const stage = document.getElementById("rec-stage");
|
|
const controls = document.getElementById("rec-controls");
|
|
const verifyBanner = document.getElementById("rec-verify-banner");
|
|
|
|
closeRecordingOverlay();
|
|
overlay.classList.remove("hidden");
|
|
title.textContent = `Wiedergabe -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`;
|
|
loading.classList.remove("hidden");
|
|
loading.textContent = "Aufzeichnung wird geladen ...";
|
|
stage.classList.add("hidden");
|
|
controls.classList.add("hidden");
|
|
verifyBanner.classList.add("hidden");
|
|
|
|
try {
|
|
const result = await getJson(`/admin/sessions/${session.id}/recording/entries`);
|
|
loading.classList.add("hidden");
|
|
stage.classList.remove("hidden");
|
|
controls.classList.remove("hidden");
|
|
|
|
if (!result.verified) {
|
|
verifyBanner.className = "banner error";
|
|
verifyBanner.textContent = "WARNUNG: Hash-Kette dieser Aufzeichnung ist gebrochen -- moeglicherweise nachtraeglich veraendert oder beschaedigt. Wiedergabe ist trotzdem moeglich.";
|
|
verifyBanner.classList.remove("hidden");
|
|
}
|
|
|
|
if (result.entries.length === 0) {
|
|
loading.classList.remove("hidden");
|
|
loading.textContent = "Diese Aufzeichnung enthaelt keine Eintraege.";
|
|
stage.classList.add("hidden");
|
|
controls.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
activePlayer = result.protocol === "rdp" ? createRdpPlayer(result.entries) : createSshPlayer(result.entries);
|
|
} catch (err) {
|
|
loading.classList.remove("hidden");
|
|
loading.textContent = "Fehler beim Laden der Aufzeichnung: " + err.message;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Verbindungslog (nur Super-Admin) -- Live-Tail der Anwendungslogs
|
|
// (inkl. Debug fuer SSH/RDP-Verbindungsaufbau) per WebSocket.
|
|
// ---------------------------------------------------------------------
|
|
|
|
let logSocket = null;
|
|
let logLines = [];
|
|
const LOG_MAX_LINES = 2000;
|
|
|
|
function connectLogStream() {
|
|
if (logSocket && (logSocket.readyState === WebSocket.OPEN || logSocket.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
logSocket = new WebSocket(`${proto}//${window.location.host}/ws/logs`);
|
|
setConnLogStatus("verbinde...");
|
|
logSocket.addEventListener("open", () => setConnLogStatus("verbunden"));
|
|
logSocket.addEventListener("close", () => setConnLogStatus("getrennt"));
|
|
logSocket.addEventListener("error", () => setConnLogStatus("Fehler"));
|
|
logSocket.addEventListener("message", (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === "line") appendLogLine(msg.line);
|
|
} catch (_err) {
|
|
// ungueltige Nachricht ignorieren
|
|
}
|
|
});
|
|
}
|
|
|
|
function disconnectLogStream() {
|
|
if (logSocket) {
|
|
logSocket.close();
|
|
logSocket = null;
|
|
}
|
|
setConnLogStatus("getrennt");
|
|
}
|
|
|
|
function setConnLogStatus(text) {
|
|
const el2 = document.getElementById("connlog-status");
|
|
if (el2) el2.textContent = text;
|
|
}
|
|
|
|
function appendLogLine(line) {
|
|
logLines.push(line);
|
|
if (logLines.length > LOG_MAX_LINES) logLines = logLines.slice(-LOG_MAX_LINES);
|
|
renderConnLog();
|
|
}
|
|
|
|
function renderConnLog() {
|
|
const pre = document.getElementById("connlog-output");
|
|
if (!pre) return;
|
|
const filter = document.getElementById("connlog-filter").value.trim().toLowerCase();
|
|
const filtered = filter ? logLines.filter((l) => l.toLowerCase().includes(filter)) : logLines;
|
|
const wasAtBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20;
|
|
pre.textContent = filtered.join("\n");
|
|
if (wasAtBottom) pre.scrollTop = pre.scrollHeight;
|
|
}
|
|
|
|
async function loadConnLogTab() {
|
|
connectLogStream();
|
|
document.getElementById("connlog-filter").addEventListener("input", renderConnLog);
|
|
document.getElementById("connlog-clear-btn").addEventListener("click", () => {
|
|
logLines = [];
|
|
renderConnLog();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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_any_admin) {
|
|
window.location.href = "/dashboard";
|
|
return;
|
|
}
|
|
meInfo = me;
|
|
const roleLabel = me.is_admin
|
|
? "Super-Admin"
|
|
: `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`;
|
|
document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`;
|
|
document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin);
|
|
document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin);
|
|
document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin);
|
|
loadedTabs.add("users");
|
|
await loadUsersTab();
|
|
}
|
|
|
|
main().catch((err) => showBanner(err.message, "error"));
|
|
})();
|