umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -1,13 +1,14 @@
(() => {
"use strict";
const ROLE_NAMES = [
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup", "credentials_view", "credentials_manage",
];
// Wird beim ersten Aufruf von loadRolesTab() aus GET /admin/roles/names
// gefuellt (Teil D.6 Schritt 1: keine Hartkodierung mehr, siehe
// Umsetzungsauftrag_Sonnet5.md D.5 -- die Rollenliste soll aus der DB
// kommen, nicht aus dem Frontend-Code dupliziert werden).
let ROLE_NAMES = [];
const bannerBox = document.getElementById("banner-box");
let meInfo = { is_admin: false, tenant_admin_of: [] };
let meInfo = { is_admin: false };
function showBanner(message, type) {
const div = document.createElement("div");
@ -111,7 +112,6 @@
credentials: loadCredentialsTab,
roles: loadRolesTab,
tokens: loadTokensTab,
tenants: loadTenantsTab,
sessions: loadSessionsTab,
connlog: loadConnLogTab,
audit: loadAuditTab,
@ -139,28 +139,6 @@
}
});
// ---------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------
@ -173,16 +151,7 @@
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(
@ -217,7 +186,6 @@
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,
]);
@ -277,8 +245,6 @@
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();
}
@ -292,7 +258,6 @@
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)),
@ -367,9 +332,7 @@
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 });
await sendJson("/admin/user-groups", "POST", { name, description });
showBanner(`Benutzergruppe '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshGroups();
@ -400,12 +363,11 @@
let cachedHosts = [];
let cachedSshKeys = [];
let cachedRdpCredentials = [];
let cachedSshPasswordCredentials = [];
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();
}
@ -420,7 +382,6 @@
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 () => {
@ -472,7 +433,6 @@
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)),
@ -501,6 +461,10 @@
cachedRdpCredentials = await getJson("/admin/rdp-credentials");
}
async function refreshSshPasswordCredentialsCache() {
cachedSshPasswordCredentials = await getJson("/admin/ssh-password-credentials");
}
function setDetailBanner(message, type) {
const box = document.getElementById("host-detail-banner");
if (!message) {
@ -540,7 +504,7 @@
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("host-detail-name").textContent = `${host.hostname} (#${host.id})`;
document.getElementById("he-hostname").value = host.hostname;
document.getElementById("he-address").value = host.address;
@ -640,9 +604,7 @@
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 });
await sendJson("/admin/host-groups", "POST", { name, description });
showBanner(`Hostgruppe '${name}' angelegt.`, "ok");
ev.target.reset();
await refreshHostGroups();
@ -760,12 +722,6 @@
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()]);
}
@ -780,7 +736,6 @@
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 || "-" }),
@ -899,7 +854,6 @@
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,
@ -908,7 +862,6 @@
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");
@ -932,7 +885,6 @@
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(", ")
@ -998,13 +950,11 @@
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");
@ -1020,13 +970,21 @@
// ---------------------------------------------------------------------
async function loadRolesTab() {
roleCheckboxGrid(document.getElementById("rg-role-grid"));
if (ROLE_NAMES.length === 0) {
ROLE_NAMES = await getJson("/admin/roles/names");
}
roleCheckboxGrid(document.getElementById("grg-role-grid"));
await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
await Promise.all([refreshSshKeysCache(), refreshRdpCredentialsCache(), refreshSshPasswordCredentialsCache()]);
populateCredentialSelect();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants(), refreshGroupCredentialGrants()]);
}
async function refreshRoleGrants() {
// Teil D Schritt 5: abgeleitete Sicht (GET /admin/roles liefert seither
// effektive Rechte statt einer Vergabe-Tabelle) -- nicht editierbar,
// daher keine Aktionen-Spalte mehr. Aendern laeuft ausschliesslich ueber
// die Gruppenvergabe (refreshGroupRoleGrants() unten).
const grants = await getJson("/admin/roles");
const tbody = document.querySelector("#role-grants-table tbody");
fillTable(
@ -1036,15 +994,8 @@
el("td", { textContent: g.username }),
el("td", { textContent: g.host_group_name }),
el("td", { textContent: g.role_name }),
el("td", { textContent: g.via_group_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();
}),
]),
])
)
);
@ -1074,25 +1025,6 @@
);
}
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 {
@ -1112,6 +1044,84 @@
}
});
// ---------------------------------------------------------------------
// Zugangsdaten-Freigabe an Benutzergruppen (Achse B, Teil D.6 Schritt 5)
// ---------------------------------------------------------------------
const CREDENTIAL_KIND_LABELS = {
ssh_key: "SSH-Schluessel",
rdp_credential: "RDP-Zugangsdaten",
ssh_password_credential: "SSH-Passwort",
};
function credentialListForKind(kind) {
if (kind === "ssh_key") return cachedSshKeys;
if (kind === "rdp_credential") return cachedRdpCredentials;
return cachedSshPasswordCredentials;
}
function credentialLabel(kind, credentialId) {
const item = credentialListForKind(kind).find((c) => c.id === credentialId);
return item ? `${item.label} (#${item.id})` : `#${credentialId}`;
}
function populateCredentialSelect() {
const kind = document.getElementById("gcg-kind").value;
const select = document.getElementById("gcg-credential");
select.replaceChildren(...optionsFromList(credentialListForKind(kind), "id", (c) => `${c.label} (#${c.id})`));
}
document.getElementById("gcg-kind").addEventListener("change", populateCredentialSelect);
async function refreshGroupCredentialGrants() {
const kinds = ["ssh_key", "rdp_credential", "ssh_password_credential"];
const perKind = await Promise.all(kinds.map((kind) => getJson(`/admin/group-credentials/${kind}`)));
const rows = [];
kinds.forEach((kind, idx) => {
for (const g of perKind[idx]) rows.push({ kind, ...g });
});
rows.sort((a, b) => a.user_group_name.localeCompare(b.user_group_name) || a.kind.localeCompare(b.kind));
const tbody = document.querySelector("#group-credential-grants-table tbody");
fillTable(
tbody,
rows.map((g) =>
el("tr", {}, [
el("td", { textContent: g.user_group_name }),
el("td", { textContent: CREDENTIAL_KIND_LABELS[g.kind] || g.kind }),
el("td", { textContent: credentialLabel(g.kind, g.credential_id) }),
el("td", { textContent: g.expires_at || "-" }),
el("td", {}, [
actionButton("Entziehen", "btn-danger", async () => {
await sendJson(`/admin/group-credentials/${g.kind}/revoke`, "POST", {
user_group_id: g.user_group_id, credential_id: g.credential_id,
});
await refreshGroupCredentialGrants();
}),
]),
])
)
);
}
document.getElementById("group-credential-grant-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const kind = document.getElementById("gcg-kind").value;
const credentialId = Number(document.getElementById("gcg-credential").value);
if (!credentialId) throw new Error("Zugangsdatensatz auswaehlen.");
await sendJson(`/admin/group-credentials/${kind}/grant`, "POST", {
user_group_id: Number(document.getElementById("gcg-group").value),
credential_id: credentialId,
expires_at: document.getElementById("gcg-expires").value.trim() || null,
});
showBanner(`Zugangsdatensatz freigegeben: ${CREDENTIAL_KIND_LABELS[kind]}.`, "ok");
document.getElementById("gcg-expires").value = "";
await refreshGroupCredentialGrants();
} catch (err) {
showBanner(err.message, "error");
}
});
// ---------------------------------------------------------------------
// API-Tokens
// ---------------------------------------------------------------------
@ -1135,8 +1145,6 @@
})
);
}
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();
}
@ -1166,7 +1174,6 @@
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 }),
@ -1183,13 +1190,11 @@
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");
@ -1204,125 +1209,6 @@
}
});
// ---------------------------------------------------------------------
// 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.
@ -1874,12 +1760,10 @@
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 gcgGroup = document.getElementById("gcg-group");
if (gcgGroup) gcgGroup.replaceChildren(...groupOpts());
const grgHostgroup = document.getElementById("grg-hostgroup");
if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts());
const tcUser = document.getElementById("tc-user");
@ -1897,16 +1781,12 @@
async function main() {
const me = await getJson("/auth/me");
if (!me.is_any_admin) {
if (!me.is_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("whoami").textContent = `${me.username} (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");