Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung.

This commit is contained in:
Dominic Gundel committed 2026-10-15 10:53:12 +02:00
1 parent b20f8a7779
commit 5a3716b26a
65 files changed
+2212 -198

No files matched your search

+216 -26
View File
@@ -29,7 +29,7 @@
<p id="auto-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem">
@@ -41,7 +41,7 @@
<p id="fqdn-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
<div class="table-wrap">
@@ -72,7 +72,7 @@
<p id="printer-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem">
@@ -101,7 +101,7 @@
<p id="tg-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<div class="grid-2">
@@ -115,10 +115,61 @@
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
<section class="card admin-only">
<h2>Benutzer</h2>
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Rolle</th><th>Aktiv</th><th></th></tr></thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<form id="user-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="user-name">Benutzer</label><input id="user-name" required minlength="2" autocomplete="off"></div>
<div class="field"><label for="user-pass">Passwort</label><input id="user-pass" type="password" required minlength="8" autocomplete="new-password"></div>
<div class="field">
<label for="user-role">Rolle</label>
<select id="user-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Anlegen</button></div>
</form>
<p id="user-note"></p>
</section>
<section class="card">
<h2>API-Tokens</h2>
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rolle</th><th></th></tr></thead>
<tbody id="token-rows"></tbody>
</table>
</div>
<form id="token-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="token-name">Bezeichnung</label><input id="token-name" placeholder="Home Assistant" required></div>
<div class="field">
<label for="token-role">Rolle</label>
<select id="token-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Token erzeugen</button></div>
</form>
<p id="token-note"></p>
<p class="ok" id="token-once" hidden></p>
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
</section>
{% endblock %}
{% block script %}
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
if (!isAdmin) {
const roleSel = document.getElementById("token-role");
if (roleSel) {
[...roleSel.options].forEach((opt) => { if (opt.value === "admin") opt.remove(); });
}
}
/* showNote aus api.js: Inline-Hinweis + Toast */
@@ -181,36 +232,175 @@ function renderPrinters(rows) {
});
}
function renderUsers(rows) {
const body = document.getElementById("user-rows");
if (!body) return;
body.innerHTML = "";
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.username)}</td>
<td><select data-f="role"><option value="operator">Operator</option><option value="admin">Admin</option></select></td>
<td><label class="check"><input type="checkbox" data-f="active"></label></td><td></td>`;
tr.querySelector('[data-f="role"]').value = row.role;
tr.querySelector('[data-f="active"]').checked = !!row.active;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
try {
await api("/users/" + row.id, {
method: "PATCH",
json: {
role: tr.querySelector('[data-f="role"]').value,
active: tr.querySelector('[data-f="active"]').checked,
},
});
showNote("user-note", null, "Benutzer gespeichert.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Löschen";
del.addEventListener("click", () => {
armButton(del, "Wirklich löschen?", async () => {
try {
await api("/users/" + row.id, { method: "DELETE" });
showNote("user-note", null, "Benutzer gelöscht.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
});
tr.children[3].appendChild(save);
tr.children[3].appendChild(del);
body.appendChild(tr);
});
}
function renderTokens(rows) {
const body = document.getElementById("token-rows");
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="4">Keine Tokens.</td></tr>';
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.name)}${row.username ? ' <span class="tiny">(' + escapeHtml(row.username) + ")</span>" : ""}</td>
<td><code>${escapeHtml(row.prefix)}…</code></td>
<td>${escapeHtml(row.role)}</td><td></td>`;
const rev = document.createElement("button");
rev.type = "button";
rev.className = "ghost warn";
rev.textContent = "Widerrufen";
rev.addEventListener("click", () => {
armButton(rev, "Widerrufen?", async () => {
try {
await api("/tokens/" + row.id, { method: "DELETE" });
showNote("token-note", null, "Token widerrufen.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
});
tr.children[3].appendChild(rev);
body.appendChild(tr);
});
}
async function loadUsers() {
if (!isAdmin) return;
renderUsers(await api("/users"));
}
async function loadTokens() {
renderTokens(await api("/tokens" + (isAdmin ? "?all=1" : "")));
}
async function load() {
const [settings, printers] = await Promise.all([
api("/einstellungen"),
api("/drucker?live=0"),
]);
renderPrinters(printers);
if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
document.getElementById("fqdn").value = settings.fqdn || "";
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled");
if (tgEnabled) {
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens()]);
}
document.getElementById("user-add")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/users", {
method: "POST",
json: {
username: document.getElementById("user-name").value,
password: document.getElementById("user-pass").value,
role: document.getElementById("user-role").value,
},
});
document.getElementById("user-add").reset();
showNote("user-note", null, "Benutzer angelegt.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
document.getElementById("token-add").addEventListener("submit", async (event) => {
event.preventDefault();
const once = document.getElementById("token-once");
once.hidden = true;
try {
const out = await api("/tokens", {
method: "POST",
json: {
name: document.getElementById("token-name").value,
role: document.getElementById("token-role").value,
},
});
document.getElementById("token-add").reset();
once.hidden = false;
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {