more admin stuff

This commit is contained in:
2026-08-20 14:49:19 +02:00
parent 91758a2701
commit 0e67092ba0
14 changed files with 3030 additions and 247 deletions

View File

@ -5,8 +5,15 @@
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
];
const AUTH_EVENT_TYPES = new Set([
"login_password_ok", "login_failed", "login_success", "login_totp_failed",
"login_recovery_code_used", "totp_enroll_started", "totp_enroll_confirmed",
"totp_enroll_failed", "logout", "logout_everywhere", "password_changed",
]);
const FAILURE_EVENT_TYPES = new Set(["login_failed", "login_totp_failed", "totp_enroll_failed"]);
const bannerBox = document.getElementById("banner-box");
let meInfo = { is_admin: false, tenant_admin_of: [] };
function showBanner(message, type) {
const div = document.createElement("div");
@ -80,6 +87,24 @@
});
}
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
// ---------------------------------------------------------------------
@ -88,8 +113,11 @@
users: loadUsersTab,
groups: loadGroupsTab,
hosts: loadHostsTab,
credentials: loadCredentialsTab,
roles: loadRolesTab,
tokens: loadTokensTab,
tenants: loadTenantsTab,
authlog: loadAuthLogTab,
audit: loadAuditTab,
};
const loadedTabs = new Set();
@ -108,23 +136,57 @@
}
});
// ---------------------------------------------------------------------
// 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 () => {
@ -134,12 +196,25 @@
})
);
}
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,
]);
@ -147,12 +222,37 @@
);
}
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 = document.getElementById("uc-is-admin").checked;
const isAdmin = meInfo.is_admin && document.getElementById("uc-is-admin").checked;
await sendJson("/admin/users", "POST", {
username, initial_password: password, is_admin: isAdmin,
});
@ -171,8 +271,11 @@
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();
}
@ -186,15 +289,44 @@
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");
@ -232,7 +364,9 @@
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 });
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();
@ -263,9 +397,12 @@
let cachedHosts = [];
let cachedSshKeys = [];
let activeHostId = null;
let editingHostGroupId = null;
async function loadHostsTab() {
await Promise.all([refreshHostGroups(), refreshSshKeys()]);
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()]);
await refreshHosts();
}
@ -279,6 +416,15 @@
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();
}),
]),
])
)
);
@ -286,6 +432,28 @@
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}`;
@ -300,40 +468,124 @@
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))]),
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 refreshSshKeys() {
async function refreshSshKeysCache() {
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})`));
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 }));
}
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-ssh-username").value = host.ssh_username || "";
document.getElementById("he-rdp-username").value = host.rdp_username || "";
document.getElementById("he-rdp-domain").value = host.rdp_domain || "";
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-active").checked = host.is_active;
const isRdp = host.protocol === "rdp";
document.getElementById("he-ssh-username-box").classList.toggle("hidden", isRdp);
document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp);
document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp);
document.getElementById("he-nla-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", {}, [
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("rdp-cred-status").textContent = host.rdp_credentials_set
? `RDP-Passwort ist gesetzt (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
: "Noch kein RDP-Passwort gesetzt.";
} 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),
ssh_username: document.getElementById("he-ssh-username").value.trim() || null,
rdp_username: document.getElementById("he-rdp-username").value.trim() || null,
rdp_domain: document.getElementById("he-rdp-domain").value.trim() || null,
clipboard_enabled: document.getElementById("he-clipboard").checked,
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
rdp_require_nla: document.getElementById("he-nla").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-ssh-username-box").classList.toggle("hidden", isRdp);
@ -349,7 +601,9 @@
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 });
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();
@ -396,7 +650,7 @@
box.classList.remove("hidden");
showBanner("Host-Key ermittelt und gespeichert.", "ok");
} catch (err) {
showBanner(err.message, "error");
setDetailBanner(err.message, "error");
}
});
@ -406,8 +660,9 @@
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) {
showBanner(err.message, "error");
setDetailBanner(err.message, "error");
}
});
@ -418,6 +673,89 @@
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
ev.target.reset();
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);
await populateTenantSelect(document.getElementById("skc-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.key_type }),
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
el("td", { textContent: k.tenant_name }),
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-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
document.getElementById("skr-type").value = k.key_type;
}
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const ownerRaw = document.getElementById("ske-owner").value.trim();
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
label: document.getElementById("ske-label").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : 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,
});
showBanner("SSH-Key rotiert.", "ok");
ev.target.reset();
await refreshSshKeys();
} catch (err) {
showBanner(err.message, "error");
}
@ -427,42 +765,61 @@
ev.preventDefault();
try {
const ownerRaw = document.getElementById("skc-owner").value.trim();
const tenantSelect = document.getElementById("skc-tenant");
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,
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();
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");
}
});
async function refreshRdpCredentials() {
const rows = await getJson("/admin/rdp-credentials");
const tbody = document.querySelector("#rdp-creds-table tbody");
fillTable(
tbody,
rows.map((r) =>
el("tr", {}, [
el("td", { textContent: r.hostname }),
el("td", { textContent: r.address }),
el("td", { textContent: r.host_group_name }),
el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]),
el("td", { textContent: r.updated_at || "-" }),
el("td", {}, [
actionButton("Zum Host", "btn-secondary", async () => {
document.querySelector('.tab-btn[data-tab="hosts"]').click();
await showHostDetail(r.host_id);
}),
...(r.credentials_set
? [actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/hosts/${r.host_id}/rdp-credentials`, { method: "DELETE" });
showBanner("RDP-Passwort entfernt.", "ok");
await refreshRdpCredentials();
})]
: []),
]),
])
)
);
}
// ---------------------------------------------------------------------
// 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());
roleCheckboxGrid(document.getElementById("rg-role-grid"));
roleCheckboxGrid(document.getElementById("grg-role-grid"));
await refreshSharedSelects();
await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants()]);
}
@ -518,13 +875,16 @@
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_name: document.getElementById("rg-role").value,
role_names,
expires_at: document.getElementById("rg-expires").value.trim() || null,
});
showBanner("Rolle vergeben.", "ok");
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");
@ -534,13 +894,16 @@
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_name: document.getElementById("grg-role").value,
role_names,
expires_at: document.getElementById("grg-expires").value.trim() || null,
});
showBanner("Rolle an Gruppe vergeben.", "ok");
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");
@ -570,6 +933,8 @@
})
);
}
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();
}
@ -599,6 +964,7 @@
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 }),
@ -615,11 +981,13 @@
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");
@ -634,6 +1002,156 @@
}
});
// ---------------------------------------------------------------------
// 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");
}
});
// ---------------------------------------------------------------------
// Login-Verlauf (Auth-Log) -- clientseitig aus dem Audit-Log gefiltert
// ---------------------------------------------------------------------
async function loadAuthLogTab() {
await refreshAuthLog();
}
async function refreshAuthLog() {
const entries = await getJson("/admin/audit-log?limit=500");
const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type));
const tbody = document.querySelector("#authlog-table tbody");
fillTable(
tbody,
authEntries.map((e) =>
el("tr", {}, [
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("span", {
className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`,
textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich",
}),
]),
])
)
);
}
// ---------------------------------------------------------------------
// Audit-Log
// ---------------------------------------------------------------------
@ -710,11 +1228,16 @@
async function main() {
const me = await getJson("/auth/me");
if (!me.is_admin) {
if (!me.is_any_admin) {
window.location.href = "/dashboard";
return;
}
document.getElementById("whoami").textContent = `${me.username} (Admin)`;
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);
loadedTabs.add("users");
await loadUsersTab();
}

View File

@ -5,6 +5,12 @@
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
*
* Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload)
* mit Formularen fuer Upload UND Download in beide Richtungen. Der Download
* laeuft ueber fetch() + Blob + synthetischen <a download>-Link, damit
* Fehler inline im Panel angezeigt werden koennen statt die Seite zu
* verlassen (siehe GET /ssh/{host_id}/files/download).
*/
(() => {
"use strict";
@ -73,17 +79,100 @@
});
document.addEventListener("fullscreenchange", sendResize);
const fileInput = document.getElementById("file-input");
document.getElementById("upload-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`);
if (!remotePath) return;
// --- Dateitransfer-Panel ------------------------------------------------
const ftOverlay = document.getElementById("ft-overlay");
const ftStatus = document.getElementById("ft-status");
const ftList = document.getElementById("ft-list");
const transfers = [];
function openFt() {
ftOverlay.classList.remove("hidden");
ftStatus.textContent = "";
}
function closeFt() {
ftOverlay.classList.add("hidden");
}
document.getElementById("filetransfer-btn").addEventListener("click", openFt);
document.getElementById("ft-close-btn").addEventListener("click", closeFt);
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function recordTransfer(direction, filename, size, ok, message) {
transfers.unshift({ direction, filename, size, ok, message });
if (transfers.length > 20) transfers.length = 20;
renderTransfers();
}
function renderTransfers() {
ftList.innerHTML = "";
if (transfers.length === 0) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "Noch keine Transfers in dieser Sitzung.";
ftList.appendChild(empty);
return;
}
for (const t of transfers) {
const row = document.createElement("div");
row.className = "ft-list-row";
const dir = document.createElement("span");
dir.className = "badge " + (t.ok ? "ok" : "danger");
dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download";
row.appendChild(dir);
const name = document.createElement("span");
name.className = "name";
name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`;
row.appendChild(name);
if (t.ok) {
const size = document.createElement("span");
size.className = "size";
size.textContent = formatSize(t.size);
row.appendChild(size);
}
ftList.appendChild(row);
}
}
renderTransfers();
// Upload
const uploadRemotePathInput = document.getElementById("ft-upload-remote-path");
const uploadFileInput = document.getElementById("ft-upload-file-input");
const uploadFilenameLabel = document.getElementById("ft-upload-filename");
document.getElementById("ft-upload-pick-btn").addEventListener("click", () => uploadFileInput.click());
uploadFileInput.addEventListener("change", () => {
const file = uploadFileInput.files[0];
uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt";
if (file && !uploadRemotePathInput.value) {
uploadRemotePathInput.value = `/tmp/${file.name}`;
}
});
document.getElementById("ft-upload-submit-btn").addEventListener("click", async () => {
const file = uploadFileInput.files[0];
const remotePath = uploadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!file) {
ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen.";
return;
}
if (!remotePath) {
ftStatus.textContent = "Bitte einen Zielpfad angeben.";
return;
}
const formData = new FormData();
formData.append("file", file);
statusEl.textContent = `Lade ${file.name} hoch ...`;
ftStatus.textContent = `Lade ${file.name} hoch ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
@ -91,10 +180,58 @@
);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
statusEl.textContent = `Upload ok (AV: ${data.av_scan_result})`;
ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
recordTransfer("upload", file.name, data.size, true, "");
uploadFileInput.value = "";
uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt";
uploadRemotePathInput.value = "";
} catch (err) {
statusEl.textContent = "Upload-Fehler: " + err.message;
ftStatus.textContent = "Upload-Fehler: " + err.message;
recordTransfer("upload", file.name, 0, false, err.message);
}
});
// Download
const downloadRemotePathInput = document.getElementById("ft-download-remote-path");
document.getElementById("ft-download-submit-btn").addEventListener("click", async () => {
const remotePath = downloadRemotePathInput.value.trim();
ftStatus.textContent = "";
if (!remotePath) {
ftStatus.textContent = "Bitte einen Pfad angeben.";
return;
}
const filename = remotePath.split("/").filter(Boolean).pop() || "download";
ftStatus.textContent = `Lade ${filename} herunter ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "GET", credentials: "same-origin" }
);
if (!res.ok) {
let message = "Download fehlgeschlagen";
try {
const data = await res.json();
message = data.detail || message;
} catch (_) { /* Antwort war kein JSON */ }
throw new Error(message);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
ftStatus.textContent = `Download abgeschlossen: ${filename}`;
recordTransfer("download", filename, blob.size, true, "");
downloadRemotePathInput.value = "";
} catch (err) {
ftStatus.textContent = "Download-Fehler: " + err.message;
recordTransfer("download", filename, 0, false, err.message);
}
fileInput.value = "";
});
})();