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
+12
View File
@@ -853,3 +853,15 @@ nav [class*="font-bold"] {
}
.hosting-tab:hover { color: var(--rapport-text); }
.hosting-tab.active { color: var(--rapport-accent); border-bottom-color: var(--rapport-accent); }
/* Admin-Panel */
.admin-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
@media (max-width: 700px) { .admin-stats { grid-template-columns: repeat(2, 1fr); } }
.admin-stat { background: var(--rapport-surface2); border: 1px solid var(--rapport-border); border-radius: 12px; padding: 18px; text-align: center; }
.dark .admin-stat { background: #1a1714; border-color: #2d2926; }
.admin-stat-num { font-family: 'Playfair Display', serif; font-size: 30px; font-weight: 700; color: var(--rapport-text); line-height: 1; }
.admin-stat-label { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--rapport-text-3); margin-top: 8px; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.admin-table th { text-align: left; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--rapport-text-3); padding: 10px 12px; border-bottom: 1px solid var(--rapport-border); }
.admin-table td { padding: 12px; border-bottom: 1px solid var(--rapport-border); vertical-align: top; }
.admin-tag { display: inline-block; background: var(--rapport-accent); color: #fff; font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 7px; border-radius: 5px; vertical-align: middle; }
+7
View File
@@ -0,0 +1,7 @@
---
title: Admin
layout: appshell
hostingpage: admin
toc: false
noindex: true
---
+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)();
})();