add admin stuff
This commit is contained in:
@ -31,7 +31,7 @@ body {
|
||||
}
|
||||
.card h1 { font-size: 1.25rem; margin: 0 0 1.25rem; }
|
||||
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.75rem 0 0.25rem; }
|
||||
input[type=text], input[type=password] {
|
||||
input[type=text], input[type=password], select {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: #0f1218;
|
||||
@ -40,6 +40,7 @@ input[type=text], input[type=password] {
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
select { appearance: auto; }
|
||||
button {
|
||||
margin-top: 1.25rem;
|
||||
width: 100%;
|
||||
@ -69,7 +70,10 @@ button:hover { filter: brightness(1.1); }
|
||||
}
|
||||
.topbar .brand { font-weight: 700; }
|
||||
.topbar button { width: auto; margin: 0; padding: 0.4rem 0.9rem; font-size: 0.85rem; }
|
||||
.topbar a { color: var(--accent); text-decoration: none; font-size: 0.85rem; margin-right: 1rem; }
|
||||
.topbar a:hover { text-decoration: underline; }
|
||||
.container { padding: 1.25rem; max-width: 960px; margin: 0 auto; }
|
||||
.container.wide { max-width: 1200px; }
|
||||
.group { margin-bottom: 1.5rem; }
|
||||
.group h2 { font-size: 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.host-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.75rem; }
|
||||
@ -84,6 +88,100 @@ button:hover { filter: brightness(1.1); }
|
||||
background: var(--accent); color: white; font-size: 0.85rem; margin: 0;
|
||||
}
|
||||
|
||||
/* --- Admin-Oberflaeche ------------------------------------------------- */
|
||||
|
||||
.tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tab-btn {
|
||||
width: auto; margin: 0 0 -1px; padding: 0.6rem 1rem; background: transparent;
|
||||
border: none; border-bottom: 2px solid transparent; border-radius: 0;
|
||||
color: var(--muted); font-weight: 600; font-size: 0.85rem;
|
||||
}
|
||||
.tab-btn:hover { filter: none; color: var(--text); }
|
||||
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab-panel { display: block; }
|
||||
.tab-panel.hidden { display: none !important; }
|
||||
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1.25rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.panel h2 { font-size: 1rem; margin: 0 0 1rem; }
|
||||
.panel h3 { font-size: 0.9rem; color: var(--muted); margin: 1.5rem 0 0.75rem; }
|
||||
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
.form-grid label { margin: 0 0 0.25rem; }
|
||||
.form-grid .full-width { grid-column: 1 / -1; }
|
||||
.form-grid button { margin-top: 0; grid-column: 1 / -1; width: auto; padding: 0.55rem 1.25rem; justify-self: start; }
|
||||
textarea {
|
||||
width: 100%; min-height: 5rem; padding: 0.6rem 0.7rem; background: #0f1218;
|
||||
border: 1px solid var(--border); border-radius: 6px; color: var(--text);
|
||||
font-family: monospace; font-size: 0.85rem; resize: vertical;
|
||||
}
|
||||
.checkbox-row { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.75rem; }
|
||||
.checkbox-row input[type=checkbox] { width: auto; margin: 0; }
|
||||
.checkbox-row label { margin: 0; color: var(--text); font-size: 0.9rem; }
|
||||
|
||||
.btn-secondary { background: var(--border); }
|
||||
.btn-danger { background: var(--danger); }
|
||||
.btn-small { width: auto; margin: 0; padding: 0.35rem 0.75rem; font-size: 0.8rem; }
|
||||
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.data-table th, .data-table td {
|
||||
text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.data-table th { color: var(--muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
.badge {
|
||||
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem;
|
||||
background: var(--border); color: var(--text); margin: 0 0.25rem 0.25rem 0;
|
||||
}
|
||||
.badge.ok { background: var(--ok); color: #06210f; }
|
||||
.badge.danger { background: var(--danger); color: #2a0505; }
|
||||
|
||||
.banner {
|
||||
padding: 0.6rem 0.9rem; border-radius: 6px; font-size: 0.85rem; margin-bottom: 1rem;
|
||||
}
|
||||
.banner.ok { background: rgba(34, 197, 94, 0.15); color: var(--ok); border: 1px solid var(--ok); }
|
||||
.banner.error { background: rgba(239, 68, 68, 0.15); color: var(--danger); border: 1px solid var(--danger); }
|
||||
|
||||
.reveal-box {
|
||||
font-family: monospace; background: #0f1218; border: 1px solid var(--ok); padding: 0.75rem;
|
||||
border-radius: 6px; word-break: break-all; margin-top: 0.75rem;
|
||||
}
|
||||
.scope-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.4rem 1rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.scope-grid .checkbox-row { margin-top: 0; }
|
||||
|
||||
pre.json-view {
|
||||
background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem;
|
||||
font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.endpoint-row {
|
||||
display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.endpoint-row .method {
|
||||
font-family: monospace; font-weight: 700; font-size: 0.75rem; padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px; width: 3.5rem; text-align: center; flex-shrink: 0;
|
||||
}
|
||||
.endpoint-row .method.get { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||
.endpoint-row .method.post { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
|
||||
.endpoint-row .method.put { background: rgba(234, 179, 8, 0.2); color: #facc15; }
|
||||
.endpoint-row .method.delete { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||
.endpoint-row .path { font-family: monospace; font-size: 0.85rem; }
|
||||
.endpoint-row .summary { color: var(--muted); font-size: 0.8rem; margin-left: auto; }
|
||||
.endpoint-detail { padding: 0 0 0.75rem 4.1rem; }
|
||||
|
||||
.session-shell { display: flex; flex-direction: column; height: 100vh; }
|
||||
.session-toolbar {
|
||||
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem;
|
||||
|
||||
723
static/js/admin.js
Normal file
723
static/js/admin.js
Normal file
@ -0,0 +1,723 @@
|
||||
(() => {
|
||||
"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"));
|
||||
})();
|
||||
76
static/js/api-docs.js
Normal file
76
static/js/api-docs.js
Normal file
@ -0,0 +1,76 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const METHODS_ORDER = ["get", "post", "put", "patch", "delete"];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const container = document.getElementById("endpoints");
|
||||
let spec;
|
||||
try {
|
||||
const res = await fetch("/openapi.json", { credentials: "same-origin" });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
spec = await res.json();
|
||||
} catch (err) {
|
||||
container.replaceChildren(el("div", { className: "banner error", textContent: `Konnte /openapi.json nicht laden: ${err.message}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
const byTag = {};
|
||||
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||
for (const method of METHODS_ORDER) {
|
||||
const op = methods[method];
|
||||
if (!op) continue;
|
||||
const tag = (op.tags && op.tags[0]) || "sonstige";
|
||||
(byTag[tag] = byTag[tag] || []).push({ path, method, op });
|
||||
}
|
||||
}
|
||||
|
||||
const panels = Object.keys(byTag).sort().map((tag) => {
|
||||
const rows = byTag[tag]
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.map(({ path, method, op }) => {
|
||||
const detail = el("pre", { className: "json-view hidden" });
|
||||
detail.textContent = JSON.stringify(op, null, 2);
|
||||
|
||||
const row = el("div", { className: "endpoint-row" }, [
|
||||
el("span", { className: `method ${method}`, textContent: method.toUpperCase() }),
|
||||
el("span", { className: "path", textContent: path }),
|
||||
el("span", { className: "summary", textContent: op.summary || "" }),
|
||||
]);
|
||||
row.addEventListener("click", () => detail.classList.toggle("hidden"));
|
||||
|
||||
const wrapper = document.createDocumentFragment();
|
||||
wrapper.appendChild(row);
|
||||
const detailWrap = el("div", { className: "endpoint-detail" }, [detail]);
|
||||
wrapper.appendChild(detailWrap);
|
||||
return wrapper;
|
||||
});
|
||||
|
||||
const panel = el("div", { className: "panel" }, [el("h2", { textContent: tag })]);
|
||||
for (const row of rows) panel.appendChild(row);
|
||||
return panel;
|
||||
});
|
||||
|
||||
container.replaceChildren(...panels);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
document.getElementById("endpoints").replaceChildren(
|
||||
document.createTextNode(`Fehler: ${err.message}`)
|
||||
);
|
||||
});
|
||||
})();
|
||||
@ -39,6 +39,9 @@
|
||||
async function main() {
|
||||
const me = await getJson("/auth/me");
|
||||
document.getElementById("whoami").textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`;
|
||||
if (me.is_admin) {
|
||||
document.getElementById("admin-link").classList.remove("hidden");
|
||||
}
|
||||
|
||||
const hosts = await getJson("/catalog/hosts");
|
||||
const byGroup = {};
|
||||
|
||||
Reference in New Issue
Block a user