feat(admin): Admin-Panel-Frontend (/admin)

- content/admin.md (appshell, noindex), renderAdmin in hosting-app.js:
  Kennzahlen-Kacheln (Kunden/Abos/Instanzen/MRR) + Kundentabelle
- Admin-Link im Konto-Header (nur wenn account.is_admin)
- Admin-Styles (Stat-Kacheln, Tabelle) in custom.css

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 00:04:31 +02:00
parent a061bfa18e
commit 4cd3e56f89
3 changed files with 72 additions and 2 deletions
+53 -2
View File
@@ -126,7 +126,9 @@
const head =
'<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>' +
'<button class="hosting-link" id="logout">Abmelden</button></div>' +
'<div style="display:flex;gap:14px;align-items:center">' +
(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-tabs">' +
tabs.map(([id, label]) =>
@@ -274,5 +276,54 @@
});
}
({ login: renderLogin, register: renderRegister, konto: renderKonto, preise: renderPreise }[page] || renderLogin)();
// ── Admin / Betreiber-Bereich ────────────────────────────────────────────
async function renderAdmin() {
if (!tok.isLoggedIn) return go("/login/");
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let stats, accounts;
try {
[stats, accounts] = await Promise.all([
api("GET", "/admin/stats"),
api("GET", "/admin/accounts"),
]);
} catch (err) {
if (/angemeldet|abgelaufen|ungültig/i.test(err.message)) { tok.clear(); return go("/login/"); }
// 403 = eingeloggt, aber kein Admin
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>" +
'<a class="hosting-link" href="/konto/">Zurück zum Konto</a>');
return;
}
const stat = (label, val) =>
'<div class="admin-stat"><div class="admin-stat-num">' + esc(val) + "</div>" +
'<div class="admin-stat-label">' + label + "</div></div>";
const rows = accounts.accounts.map((a) =>
"<tr>" +
"<td><b>" + esc(a.email) + "</b>" + (a.is_admin ? ' <span class="admin-tag">Admin</span>' : "") +
(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 style=\"text-align:center\">" + esc(a.instance_count) + "</td>" +
"<td class=\"muted\" style=\"font-size:12px\">" + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" +
"</tr>"
).join("");
const html =
'<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>' +
'<a class="hosting-link" href="/konto/">Mein Konto</a></div>' +
'<div class="admin-stats">' +
stat("Kunden", stats.accounts) +
stat("Aktive Abos", stats.activeSubscriptions) +
stat("Instanzen", stats.activeInstances + "/" + stats.instances) +
stat("MRR (CHF)", stats.mrrChf) +
"</div>" +
'<div style="margin:24px 0 10px;font-weight:600;font-size:14px">Kunden</div>' +
'<table class="admin-table"><thead><tr><th>Konto</th><th>Abo</th><th>Inst.</th><th>Seit</th></tr></thead>' +
"<tbody>" + (rows || '<tr><td colspan="4" class="muted">Noch keine Kunden.</td></tr>') + "</tbody></table>";
root.innerHTML = card(html, true);
}
({ login: renderLogin, register: renderRegister, konto: renderKonto, preise: renderPreise, admin: renderAdmin }[page] || renderLogin)();
})();