feat(admin): separates Betreiber-Login im Frontend + Footer/Rechts-Links

- renderAdminLogin: eigenes Admin-Passwort-Formular, eigener Token
  (rapport_admin_token), getrennt vom Kundenkonto
- renderAdmin nutzt adminApi (Admin-Token); Abmelden-Button
- is_admin-Reste aus Konto-Header und Kundentabelle entfernt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 10:51:52 +02:00
parent cf0b1869d4
commit a4063ca985
2 changed files with 90 additions and 26 deletions
+45 -13
View File
@@ -126,9 +126,7 @@
const head = const head =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Mein Konto</div>' + '<div class="hosting-title" style="text-align:left;margin:0">Mein Konto</div>' +
'<div style="display:flex;gap:14px;align-items:center">' + '<button class="hosting-link" id="logout">Abmelden</button></div>' +
(account.is_admin ? '<a class="hosting-link" href="/admin/">Admin</a>' : "") +
'<button class="hosting-link" id="logout">Abmelden</button></div></div>' +
'<div class="hosting-sub" style="text-align:left;margin-bottom:20px">' + esc(account.email) + "</div>" + '<div class="hosting-sub" style="text-align:left;margin-bottom:20px">' + esc(account.email) + "</div>" +
'<div class="hosting-tabs">' + '<div class="hosting-tabs">' +
tabs.map(([id, label]) => tabs.map(([id, label]) =>
@@ -276,21 +274,55 @@
}); });
} }
// ── Admin / Betreiber-Bereich ──────────────────────────────────────────── // ── Admin / Betreiber-Bereich (SEPARATES Login, eigener Token) ────────────
const ADMIN_KEY = "rapport_admin_token";
const adminTok = {
get: () => localStorage.getItem(ADMIN_KEY),
set: (t) => localStorage.setItem(ADMIN_KEY, t),
clear: () => localStorage.removeItem(ADMIN_KEY),
};
// wie api(), aber mit Admin-Token statt Kunden-Token.
async function adminApi(method, path, body) {
const headers = { "Content-Type": "application/json" };
const t = adminTok.get();
if (t) headers.Authorization = `Bearer ${t}`;
const res = await fetch(`/api${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) { const e = new Error(data.error || `Fehler ${res.status}`); e.status = res.status; throw e; }
return data;
}
function renderAdminLogin(errText) {
root.innerHTML = card(
'<div class="hosting-title">Betreiber-Login</div>' +
'<div class="hosting-sub">Interner Bereich</div>' +
(errText ? '<div class="hosting-msg err">' + esc(errText) + "</div>" : "") +
'<form id="af"><label class="hosting-label">Admin-Passwort</label>' +
'<input class="hosting-input" type="password" id="apw" autofocus>' +
'<button class="hosting-btn hosting-btn-primary" id="asub">Anmelden</button></form>'
);
root.querySelector("#af").onsubmit = async (e) => {
e.preventDefault();
const sub = root.querySelector("#asub"); sub.disabled = true;
try {
const { token } = await adminApi("POST", "/admin/login", { password: root.querySelector("#apw").value });
adminTok.set(token); renderAdmin();
} catch (err) { renderAdminLogin(err.message); }
};
}
async function renderAdmin() { async function renderAdmin() {
if (!tok.isLoggedIn) return go("/login/"); if (!adminTok.get()) return renderAdminLogin("");
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true); root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let stats, accounts; let stats, accounts;
try { try {
[stats, accounts] = await Promise.all([ [stats, accounts] = await Promise.all([
api("GET", "/admin/stats"), adminApi("GET", "/admin/stats"),
api("GET", "/admin/accounts"), adminApi("GET", "/admin/accounts"),
]); ]);
} catch (err) { } catch (err) {
if (/angemeldet|abgelaufen|ungültig/i.test(err.message)) { tok.clear(); return go("/login/"); } if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); }
// 403 = eingeloggt, aber kein Admin root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>");
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>" +
'<a class="hosting-link" href="/konto/">Zurück zum Konto</a>');
return; return;
} }
@@ -300,7 +332,7 @@
const rows = accounts.accounts.map((a) => const rows = accounts.accounts.map((a) =>
"<tr>" + "<tr>" +
"<td><b>" + esc(a.email) + "</b>" + (a.is_admin ? ' <span class="admin-tag">Admin</span>' : "") + "<td><b>" + esc(a.email) + "</b>" +
(a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" + (a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" +
"<td>" + (a.plan ? esc(a.plan) + '<div class="muted" style="font-size:12px">' + esc(a.sub_status || "") + "</div>" : '<span class="muted">—</span>') + "</td>" + "<td>" + (a.plan ? esc(a.plan) + '<div class="muted" style="font-size:12px">' + esc(a.sub_status || "") + "</div>" : '<span class="muted">—</span>') + "</td>" +
"<td style=\"text-align:center\">" + esc(a.instance_count) + "</td>" + "<td style=\"text-align:center\">" + esc(a.instance_count) + "</td>" +
@@ -311,7 +343,7 @@
const html = const html =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Admin</div>' + '<div class="hosting-title" style="text-align:left;margin:0">Admin</div>' +
'<a class="hosting-link" href="/konto/">Mein Konto</a></div>' + '<button class="hosting-link" id="alogout">Abmelden</button></div>' +
'<div class="admin-stats">' + '<div class="admin-stats">' +
stat("Kunden", stats.accounts) + stat("Kunden", stats.accounts) +
stat("Aktive Abos", stats.activeSubscriptions) + stat("Aktive Abos", stats.activeSubscriptions) +
+45 -13
View File
@@ -126,9 +126,7 @@
const head = const head =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Mein Konto</div>' + '<div class="hosting-title" style="text-align:left;margin:0">Mein Konto</div>' +
'<div style="display:flex;gap:14px;align-items:center">' + '<button class="hosting-link" id="logout">Abmelden</button></div>' +
(account.is_admin ? '<a class="hosting-link" href="/admin/">Admin</a>' : "") +
'<button class="hosting-link" id="logout">Abmelden</button></div></div>' +
'<div class="hosting-sub" style="text-align:left;margin-bottom:20px">' + esc(account.email) + "</div>" + '<div class="hosting-sub" style="text-align:left;margin-bottom:20px">' + esc(account.email) + "</div>" +
'<div class="hosting-tabs">' + '<div class="hosting-tabs">' +
tabs.map(([id, label]) => tabs.map(([id, label]) =>
@@ -276,21 +274,55 @@
}); });
} }
// ── Admin / Betreiber-Bereich ──────────────────────────────────────────── // ── Admin / Betreiber-Bereich (SEPARATES Login, eigener Token) ────────────
const ADMIN_KEY = "rapport_admin_token";
const adminTok = {
get: () => localStorage.getItem(ADMIN_KEY),
set: (t) => localStorage.setItem(ADMIN_KEY, t),
clear: () => localStorage.removeItem(ADMIN_KEY),
};
// wie api(), aber mit Admin-Token statt Kunden-Token.
async function adminApi(method, path, body) {
const headers = { "Content-Type": "application/json" };
const t = adminTok.get();
if (t) headers.Authorization = `Bearer ${t}`;
const res = await fetch(`/api${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) { const e = new Error(data.error || `Fehler ${res.status}`); e.status = res.status; throw e; }
return data;
}
function renderAdminLogin(errText) {
root.innerHTML = card(
'<div class="hosting-title">Betreiber-Login</div>' +
'<div class="hosting-sub">Interner Bereich</div>' +
(errText ? '<div class="hosting-msg err">' + esc(errText) + "</div>" : "") +
'<form id="af"><label class="hosting-label">Admin-Passwort</label>' +
'<input class="hosting-input" type="password" id="apw" autofocus>' +
'<button class="hosting-btn hosting-btn-primary" id="asub">Anmelden</button></form>'
);
root.querySelector("#af").onsubmit = async (e) => {
e.preventDefault();
const sub = root.querySelector("#asub"); sub.disabled = true;
try {
const { token } = await adminApi("POST", "/admin/login", { password: root.querySelector("#apw").value });
adminTok.set(token); renderAdmin();
} catch (err) { renderAdminLogin(err.message); }
};
}
async function renderAdmin() { async function renderAdmin() {
if (!tok.isLoggedIn) return go("/login/"); if (!adminTok.get()) return renderAdminLogin("");
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true); root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let stats, accounts; let stats, accounts;
try { try {
[stats, accounts] = await Promise.all([ [stats, accounts] = await Promise.all([
api("GET", "/admin/stats"), adminApi("GET", "/admin/stats"),
api("GET", "/admin/accounts"), adminApi("GET", "/admin/accounts"),
]); ]);
} catch (err) { } catch (err) {
if (/angemeldet|abgelaufen|ungültig/i.test(err.message)) { tok.clear(); return go("/login/"); } if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); }
// 403 = eingeloggt, aber kein Admin root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>");
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>" +
'<a class="hosting-link" href="/konto/">Zurück zum Konto</a>');
return; return;
} }
@@ -300,7 +332,7 @@
const rows = accounts.accounts.map((a) => const rows = accounts.accounts.map((a) =>
"<tr>" + "<tr>" +
"<td><b>" + esc(a.email) + "</b>" + (a.is_admin ? ' <span class="admin-tag">Admin</span>' : "") + "<td><b>" + esc(a.email) + "</b>" +
(a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" + (a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" +
"<td>" + (a.plan ? esc(a.plan) + '<div class="muted" style="font-size:12px">' + esc(a.sub_status || "") + "</div>" : '<span class="muted">—</span>') + "</td>" + "<td>" + (a.plan ? esc(a.plan) + '<div class="muted" style="font-size:12px">' + esc(a.sub_status || "") + "</div>" : '<span class="muted">—</span>') + "</td>" +
"<td style=\"text-align:center\">" + esc(a.instance_count) + "</td>" + "<td style=\"text-align:center\">" + esc(a.instance_count) + "</td>" +
@@ -311,7 +343,7 @@
const html = const html =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Admin</div>' + '<div class="hosting-title" style="text-align:left;margin:0">Admin</div>' +
'<a class="hosting-link" href="/konto/">Mein Konto</a></div>' + '<button class="hosting-link" id="alogout">Abmelden</button></div>' +
'<div class="admin-stats">' + '<div class="admin-stats">' +
stat("Kunden", stats.accounts) + stat("Kunden", stats.accounts) +
stat("Aktive Abos", stats.activeSubscriptions) + stat("Aktive Abos", stats.activeSubscriptions) +