(() => { "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")); })();