Files
RAPPORT-WEBSITE/static/js/hosting-app.js
T
karim fc572fc3f3 feat(ux): eingeloggter Kunde landet im Konto statt Login/Marketing
- renderLogin/renderRegister: bei vorhandenem Token sofort → /konto/
  (behebt 'man bleibt nicht eingeloggt' — Formular wurde trotz Token gezeigt)
- nav-account.js (auf allen Seiten via custom/head-end.html): CTAs auf
  /register/ und Navbar 'Anmelden' (/login/) → 'Mein Konto' (/konto/) sobald
  eingeloggt. So führt /hosting/ den Kunden direkt ins Konto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:47:23 +02:00

546 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* RAPPORT Hosting — Frontend-Logik (Vanilla JS).
*
* Reine Client-Seite: spricht ausschließlich mit dem proprietären Backend
* unter /api (RAPPORT-HOST). Hier liegt KEINE Geschäftslogik, nur fetch +
* Rendering — so bleibt RAPPORT-WEBSITE sauber AGPL/öffentlich.
*
* Token im localStorage; gerendert wird in #hosting-root je nach data-page.
*/
(function () {
"use strict";
const root = document.getElementById("hosting-root");
if (!root) return;
const page = root.dataset.page || "login";
const TOKEN_KEY = "rapport_host_token";
const tok = {
get: () => localStorage.getItem(TOKEN_KEY),
set: (t) => localStorage.setItem(TOKEN_KEY, t),
clear: () => localStorage.removeItem(TOKEN_KEY),
get isLoggedIn() { return !!localStorage.getItem(TOKEN_KEY); },
};
async function api(method, path, body) {
const headers = { "Content-Type": "application/json" };
const t = tok.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) throw new Error(data.error || ("Fehler " + res.status));
return data;
}
const go = (p) => { window.location.href = p; };
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
function card(inner, wide) {
return '<div class="hosting-card' + (wide ? " wide" : "") + '">' + inner + "</div>";
}
// Auth-Karten als 2-Spalter: links Formular, rechts Vertrauens-Panel.
// (Login/Register) — wirkt deutlich hochwertiger als die nackte Box.
function authCard(formInner) {
return (
'<div class="hosting-auth">' +
'<div class="hosting-auth-form">' + formInner + "</div>" +
'<div class="hosting-auth-aside">' +
'<div class="rapport-logo-card" style="margin:0 0 28px"><div class="rapport-logo-text">RAPPORT</div><div class="rapport-logo-sub">Hosting</div></div>' +
'<ul class="hosting-aside-list">' +
"<li>🇨🇭 Daten in der Schweiz</li>" +
"<li>Tägliche Backups</li>" +
"<li>In Minuten startklar</li>" +
"<li>Monatlich kündbar</li>" +
"</ul>" +
"</div>" +
"</div>"
);
}
function msg(el, text, kind) {
el.innerHTML = text ? '<div class="hosting-msg ' + kind + '">' + esc(text) + "</div>" : "";
}
// ── Login ──────────────────────────────────────────────────────────────
function renderLogin() {
// Schon eingeloggt? Dann nicht das Login-Formular zeigen, sondern direkt
// ins Konto — der häufigste Grund für "ich muss mich ständig neu anmelden".
if (tok.isLoggedIn) return go("/konto/");
root.innerHTML = card(authCard(
'<div class="hosting-title">Anmelden</div>' +
'<div class="hosting-sub">Zu Ihrer Rapport-Instanz</div>' +
'<div id="m"></div>' +
'<form id="f">' +
'<label class="hosting-label">E-Mail</label>' +
'<input class="hosting-input" type="email" id="email" placeholder="name@studio.ch" autofocus>' +
'<label class="hosting-label">Passwort</label>' +
'<input class="hosting-input" type="password" id="pw" placeholder="••••••">' +
'<button class="hosting-btn hosting-btn-primary" id="sub">Anmelden</button>' +
"</form>" +
'<div class="hosting-foot">Noch kein Konto? ' +
'<button class="hosting-link" id="toReg">Registrieren</button></div>'
), true);
const m = root.querySelector("#m");
root.querySelector("#toReg").onclick = () => go("/register/");
root.querySelector("#f").onsubmit = async (e) => {
e.preventDefault();
const sub = root.querySelector("#sub"); sub.disabled = true; msg(m, "", "");
try {
const { token } = await api("POST", "/auth/login", {
email: root.querySelector("#email").value.trim(),
password: root.querySelector("#pw").value,
});
tok.set(token); go("/konto/");
} catch (err) { msg(m, err.message, "err"); sub.disabled = false; }
};
}
// ── Registrierung ──────────────────────────────────────────────────────
function renderRegister() {
if (tok.isLoggedIn) return go("/konto/");
root.innerHTML = card(authCard(
'<div class="hosting-title">Konto erstellen</div>' +
'<div class="hosting-sub">In Minuten zur eigenen Instanz</div>' +
'<div id="m"></div>' +
'<form id="f">' +
'<label class="hosting-label">E-Mail</label>' +
'<input class="hosting-input" type="email" id="email" placeholder="name@studio.ch" autofocus>' +
'<label class="hosting-label">Passwort</label>' +
'<input class="hosting-input" type="password" id="pw" placeholder="min. 8 Zeichen">' +
'<button class="hosting-btn hosting-btn-primary" id="sub">Konto erstellen</button>' +
"</form>" +
'<div class="hosting-foot">Schon ein Konto? ' +
'<button class="hosting-link" id="toLogin">Anmelden</button></div>'
), true);
const m = root.querySelector("#m");
root.querySelector("#toLogin").onclick = () => go("/login/");
root.querySelector("#f").onsubmit = async (e) => {
e.preventDefault();
const sub = root.querySelector("#sub"); sub.disabled = true; msg(m, "", "");
try {
const { token } = await api("POST", "/auth/register", {
email: root.querySelector("#email").value.trim(),
password: root.querySelector("#pw").value,
});
tok.set(token); go("/konto/");
} catch (err) { msg(m, err.message, "err"); sub.disabled = false; }
};
}
// ── Konto / Dashboard (Tabs: Übersicht · Profil · Sicherheit) ───────────
let acctData = null; // gecachte /account/me-Antwort
let acctTab = "overview";
async function renderKonto() {
if (!tok.isLoggedIn) return go("/login/");
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
try { acctData = await api("GET", "/account/me"); }
catch (err) {
if (/angemeldet|abgelaufen|ungültig/i.test(err.message)) { tok.clear(); return go("/login/"); }
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>"); return;
}
paintKonto();
}
function paintKonto() {
const { account } = acctData;
const tabs = [["overview", "Übersicht"], ["profile", "Profil"], ["security", "Sicherheit"]];
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 class="hosting-sub" style="text-align:left;margin-bottom:20px">' + esc(account.email) + "</div>" +
'<div class="hosting-tabs">' +
tabs.map(([id, label]) =>
'<button class="hosting-tab' + (acctTab === id ? " active" : "") + '" data-tab="' + id + '">' + label + "</button>"
).join("") + "</div>";
const body = { overview: tabOverview, profile: tabProfile, security: tabSecurity }[acctTab]();
root.innerHTML = card(head + '<div id="tabbody">' + body + "</div>", true);
root.querySelector("#logout").onclick = () => { tok.clear(); go("/"); };
root.querySelectorAll("[data-tab]").forEach((b) =>
(b.onclick = () => { acctTab = b.dataset.tab; paintKonto(); })
);
wireTab();
}
// — Tab: Übersicht (Abo + Instanzen) —
function tabOverview() {
const { subscription, instances, plans } = acctData;
const params = new URLSearchParams(location.search);
let h = "";
if (params.get("provisioned") === "1")
h += '<div class="hosting-msg ok">Zahlung erfolgreich — Ihre Instanz wird bereitgestellt.</div>';
if (subscription) {
const pastDue = subscription.status === "past_due";
if (pastDue) h += '<div class="hosting-msg err">Letzte Zahlung fehlgeschlagen — bitte Zahlungsmittel im Abo-Portal aktualisieren.</div>';
h += '<div class="hosting-row"><span class="muted">Abo</span><b>' +
esc(subscription.plan) + " · " + esc(subscription.status) + "</b></div>";
if (subscription.current_period_end)
h += '<div class="hosting-row"><span class="muted">Nächste Verlängerung</span><span>' +
esc(new Date(subscription.current_period_end).toLocaleDateString("de-CH")) + "</span></div>";
h += '<div style="margin-top:14px"><button class="hosting-btn hosting-btn-dark" id="managePlan" style="width:auto;padding:9px 18px">Abo verwalten</button></div>';
}
if (instances && instances.length) {
h += '<div style="margin:22px 0 10px;font-weight:600;font-size:14px">Ihre Instanzen</div>';
h += instances.map((i) =>
'<div class="hosting-row"><div><b>' + esc(i.label || i.studio_slug) + "</b>" +
'<div class="muted" style="font-size:12px">' + esc(i.instance_url) + "</div></div>" +
'<a class="hosting-btn hosting-btn-primary" style="width:auto;padding:8px 16px;text-decoration:none" href="' +
esc(i.instance_url) + '" target="_blank" rel="noreferrer">Öffnen →</a></div>'
).join("");
// Weitere Instanz (Multi-Instanz vorbereitet — Backend-Sperre öffnen wir später)
h += '<div style="margin-top:16px"><button class="hosting-btn hosting-btn-dark" id="addInstance">+ Weitere Instanz</button></div>';
} else {
h += '<div style="margin:18px 0 8px;font-size:14px;color:var(--rapport-text-2)">Wählen Sie ein Abo, um Ihre Instanz freizuschalten:</div>' +
'<div class="hosting-plans">' + (plans || []).map(planCard).join("") + "</div>";
}
return h;
}
// — Tab: Profil (Firma / Rechnungsadresse) —
function tabProfile() {
const a = acctData.account;
const f = (id, label, val, ph) =>
'<label class="hosting-label">' + label + "</label>" +
'<input class="hosting-input" id="' + id + '" value="' + esc(val || "") + '" placeholder="' + (ph || "") + '">';
return '<div id="pmsg"></div>' +
f("company", "Firma / Büro", a.company, "Architektur Muster GmbH") +
f("contact_name", "Ansprechperson", a.contact_name, "Vor- und Nachname") +
f("street", "Strasse & Nr.", a.street) +
'<div style="display:flex;gap:12px">' +
'<div style="flex:0 0 110px">' + f("zip", "PLZ", a.zip) + "</div>" +
'<div style="flex:1">' + f("city", "Ort", a.city) + "</div></div>" +
f("country", "Land", a.country || "CH") +
f("phone", "Telefon", a.phone) +
'<button class="hosting-btn hosting-btn-primary" id="saveProfile" style="margin-top:8px">Speichern</button>';
}
// — Tab: Sicherheit (Passwort) —
function tabSecurity() {
return '<div id="smsg"></div>' +
'<label class="hosting-label">Aktuelles Passwort</label>' +
'<input class="hosting-input" type="password" id="curPw">' +
'<label class="hosting-label">Neues Passwort</label>' +
'<input class="hosting-input" type="password" id="newPw" placeholder="min. 8 Zeichen">' +
'<button class="hosting-btn hosting-btn-primary" id="savePw" style="margin-top:8px">Passwort ändern</button>';
}
// — Event-Wiring je Tab —
function wireTab() {
if (acctTab === "overview") {
root.querySelectorAll("[data-plan]").forEach((b) =>
(b.onclick = async () => {
b.disabled = true;
try { const { url } = await api("POST", "/billing/checkout", { planId: b.dataset.plan }); go(url); }
catch (err) { alert(err.message); b.disabled = false; }
})
);
const add = root.querySelector("#addInstance");
if (add) add.onclick = () => alert("Weitere Instanzen kommen bald. Kontaktieren Sie uns für ein Mehrfach-Abo.");
const manage = root.querySelector("#managePlan");
if (manage) manage.onclick = async () => {
manage.disabled = true;
try { const { url } = await api("POST", "/billing/portal"); go(url); }
catch (err) { alert(err.message); manage.disabled = false; }
};
}
if (acctTab === "profile") {
root.querySelector("#saveProfile").onclick = async (e) => {
const btn = e.target; btn.disabled = true;
const m = root.querySelector("#pmsg");
const payload = {};
["company", "contact_name", "street", "zip", "city", "country", "phone"].forEach(
(id) => (payload[id] = root.querySelector("#" + id).value.trim())
);
try {
const { account } = await api("PATCH", "/account/me", payload);
acctData.account = { ...acctData.account, ...account };
msg(m, "Gespeichert.", "ok");
} catch (err) { msg(m, err.message, "err"); }
btn.disabled = false;
};
}
if (acctTab === "security") {
root.querySelector("#savePw").onclick = async (e) => {
const btn = e.target; btn.disabled = true;
const m = root.querySelector("#smsg");
try {
await api("POST", "/account/password", {
currentPassword: root.querySelector("#curPw").value,
newPassword: root.querySelector("#newPw").value,
});
root.querySelector("#curPw").value = ""; root.querySelector("#newPw").value = "";
msg(m, "Passwort geändert.", "ok");
} catch (err) { msg(m, err.message, "err"); }
btn.disabled = false;
};
}
}
function planCard(p) {
return '<div class="hosting-plan' + (p.recommended ? " rec" : "") + '">' +
(p.recommended ? '<div class="pbadge">Empfohlen</div>' : "") +
'<div class="pname">' + esc(p.name) + "</div>" +
'<div class="pprice">CHF ' + esc(p.priceChf) + '<span style="font-size:13px;color:var(--rapport-text-3)">/' + esc(p.interval) + "</span></div>" +
"<ul>" + (p.features || []).map((f) => "<li>" + esc(f) + "</li>").join("") + "</ul>" +
'<button class="hosting-btn hosting-btn-primary" data-plan="' + esc(p.id) + '">Wählen</button></div>';
}
// ── Preise (öffentlich, mit CTA in den Flow) ───────────────────────────
async function renderPreise() {
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let plans = [];
try { plans = (await api("GET", "/billing/plans")).plans; } catch (_) {}
const html =
'<div class="hosting-title">Abo wählen</div>' +
'<div class="hosting-sub">Monatlich kündbar · Preise in CHF, exkl. MwSt.</div>' +
'<div class="hosting-plans">' + plans.map(planCard).join("") + "</div>";
root.innerHTML = card(html, true);
root.querySelectorAll("[data-plan]").forEach((b) => {
b.onclick = () => go(tok.isLoggedIn ? "/konto/" : "/register/");
});
}
// ── 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() {
if (!adminTok.get()) return renderAdminLogin("");
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let stats, accounts;
try {
[stats, accounts] = await Promise.all([
adminApi("GET", "/admin/stats"),
adminApi("GET", "/admin/accounts"),
]);
} catch (err) {
if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); }
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>");
return;
}
adminCache = { stats, accounts: accounts.accounts };
paintAdmin();
}
// Such-/Filter-State des Cockpits.
let adminCache = null;
let adminSearch = "";
let adminPlanFilter = "";
function statCard(label, val, sub) {
return '<div class="admin-stat"><div class="admin-stat-num">' + esc(val) + "</div>" +
'<div class="admin-stat-label">' + esc(label) + "</div>" +
(sub ? '<div class="admin-stat-sub">' + esc(sub) + "</div>" : "") + "</div>";
}
function paintAdmin() {
const { stats } = adminCache;
const planNames = Object.values(stats.byPlan || {});
const planChips = planNames.length
? '<div class="admin-planbar">' + planNames.map((p) =>
'<div class="admin-planchip"><b>' + esc(p.name) + "</b> · " + esc(p.count) +
' <span class="muted">(CHF ' + esc(p.revenue) + ")</span></div>"
).join("") + "</div>"
: "";
// Kunden filtern (Suche über Email/Firma + Plan-Filter).
const q = adminSearch.trim().toLowerCase();
const list = adminCache.accounts.filter((a) => {
const matchesQ = !q || (a.email + " " + (a.company || "")).toLowerCase().includes(q);
const matchesP = !adminPlanFilter || a.plan === adminPlanFilter;
return matchesQ && matchesP;
});
const rows = list.map((a) =>
'<tr class="admin-row" data-id="' + esc(a.id) + '">' +
"<td><b>" + esc(a.email) + "</b>" +
(a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" +
"<td>" + (a.plan ? '<span class="admin-badge">' + esc(a.plan) + "</span>" +
(a.sub_status && a.sub_status !== "active"
? ' <span class="admin-badge ' + (a.sub_status === "past_due" ? "warn" : "") + '" style="font-size:10px">' + esc(a.sub_status) + "</span>"
: "")
: '<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>" +
'<td style="text-align:right"><span class="admin-chevron"></span></td>' +
"</tr>"
).join("");
const planOptions = ['<option value="">Alle Pläne</option>']
.concat(planNames.map((p) => '<option value="' + esc(p.name.toLowerCase()) + '"' +
(adminPlanFilter === p.name.toLowerCase() ? " selected" : "") + ">" + esc(p.name) + "</option>"))
.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">Cockpit</div>' +
'<div style="display:flex;gap:14px;align-items:center">' +
'<button class="hosting-link" id="ahealth">Health-Check</button>' +
'<button class="hosting-link" id="aexport">CSV-Export</button>' +
'<button class="hosting-link" id="alogout">Abmelden</button></div></div>' +
(stats.pastDueSubscriptions
? '<div class="hosting-msg err">⚠ ' + esc(stats.pastDueSubscriptions) +
" Abo(s) mit fehlgeschlagener Zahlung (past_due) — Kunden kontaktieren.</div>"
: "") +
'<div id="healthbar"></div>' +
'<div class="admin-stats">' +
statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") +
statCard("Aktive Abos", stats.activeSubscriptions) +
statCard("Instanzen", stats.activeInstances + "/" + stats.instances,
(stats.suspendedInstances ? stats.suspendedInstances + " gesperrt" : "")) +
statCard("MRR", "CHF " + stats.mrrChf, "ARR CHF " + (stats.arrChf || stats.mrrChf * 12)) +
"</div>" +
planChips +
'<div class="admin-toolbar">' +
'<input class="hosting-input admin-search" id="asearch" placeholder="Suche E-Mail / Firma…" value="' + esc(adminSearch) + '">' +
'<select class="hosting-input admin-filter" id="afilter">' + planOptions + "</select>" +
"</div>" +
'<table class="admin-table"><thead><tr><th>Konto</th><th>Abo</th><th>Inst.</th><th>Seit</th><th></th></tr></thead>' +
"<tbody>" + (rows || '<tr><td colspan="5" class="muted">Keine Treffer.</td></tr>') + "</tbody></table>";
root.innerHTML = card(html, true);
root.querySelector("#alogout").onclick = () => { adminTok.clear(); renderAdminLogin(""); };
root.querySelector("#aexport").onclick = async () => {
// CSV braucht den Admin-Token im Header → per fetch holen + Blob-Download.
try {
const res = await fetch("/api/admin/export/accounts.csv", {
headers: { Authorization: "Bearer " + adminTok.get() },
});
if (!res.ok) throw new Error("Export fehlgeschlagen (" + res.status + ")");
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "rapport-kunden.csv";
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(a.href);
} catch (err) { alert(err.message); }
};
root.querySelector("#ahealth").onclick = async () => {
const bar = root.querySelector("#healthbar");
bar.innerHTML = '<div class="hosting-msg">Prüfe Instanzen…</div>';
try {
const h = await adminApi("GET", "/admin/health");
if (h.mock) {
bar.innerHTML = '<div class="hosting-msg">Health-Check im Mock-Modus nicht aussagekräftig ' +
"(keine echten Instanzen). " + esc(h.instances.length) + " aktive Instanz(en).</div>";
} else if (h.down > 0) {
bar.innerHTML = '<div class="hosting-msg err">⚠ ' + esc(h.down) + " von " + esc(h.checked) +
" Instanzen nicht erreichbar (down).</div>";
} else {
bar.innerHTML = '<div class="hosting-msg ok">Alle ' + esc(h.checked) + " Instanzen erreichbar.</div>";
}
} catch (err) { bar.innerHTML = '<div class="hosting-msg err">' + esc(err.message) + "</div>"; }
};
const s = root.querySelector("#asearch");
s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); };
root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); };
root.querySelectorAll(".admin-row").forEach((r) =>
(r.onclick = () => renderAdminDetail(r.dataset.id))
);
}
// — Kunden-Detailansicht (Profil, Abo-Historie, Instanzen + Aktionen) —
async function renderAdminDetail(id) {
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let d;
try { d = await adminApi("GET", "/admin/accounts/" + id); }
catch (err) {
if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); }
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>"); return;
}
const { account: a, subscriptions, instances } = d;
const profile = [
["Firma", a.company], ["Ansprechperson", a.contact_name],
["Adresse", [a.street, [a.zip, a.city].filter(Boolean).join(" "), a.country].filter(Boolean).join(", ")],
["Telefon", a.phone],
].filter(([, v]) => v).map(([k, v]) =>
'<div class="hosting-row"><span class="muted">' + esc(k) + "</span><span>" + esc(v) + "</span></div>"
).join("") || '<div class="muted" style="padding:8px 0">Keine Profildaten.</div>';
const subRows = subscriptions.length ? subscriptions.map((s) =>
'<div class="hosting-row"><span><span class="admin-badge">' + esc(s.plan) + "</span> " + esc(s.status) +
(s.priceChf != null ? ' <span class="muted">CHF ' + esc(s.priceChf) + "</span>" : "") + "</span>" +
'<span class="muted" style="font-size:12px">' + esc(new Date(s.created_at).toLocaleDateString("de-CH")) + "</span></div>"
).join("") : '<div class="muted" style="padding:8px 0">Kein Abo.</div>';
const instRows = instances.length ? instances.map((i) =>
'<div class="hosting-row"><div><b>' + esc(i.label || i.studio_slug) + "</b> " +
'<span class="admin-badge ' + (i.status === "active" ? "ok" : "warn") + '">' + esc(i.status) + "</span>" +
'<div class="muted" style="font-size:12px">' + esc(i.instance_url) + "</div></div>" +
'<div style="display:flex;gap:8px">' +
'<a class="hosting-btn admin-mini" href="' + esc(i.instance_url) + '" target="_blank" rel="noreferrer">Öffnen</a>' +
(i.status === "active"
? '<button class="hosting-btn admin-mini warn" data-act="suspend" data-iid="' + esc(i.id) + '">Sperren</button>'
: '<button class="hosting-btn admin-mini ok" data-act="reactivate" data-iid="' + esc(i.id) + '">Reaktivieren</button>') +
"</div></div>"
).join("") : '<div class="muted" style="padding:8px 0">Keine Instanzen.</div>';
const html =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' +
'<button class="hosting-link" id="aback"> Zurück</button>' +
'<button class="hosting-link" id="alogout">Abmelden</button></div>' +
'<div class="hosting-title" style="text-align:left;margin:0 0 2px">' + esc(a.email) + "</div>" +
'<div class="hosting-sub" style="text-align:left;margin-bottom:22px">Kunde seit ' +
esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</div>" +
'<div class="admin-section">Profil</div>' + profile +
'<div class="admin-section">Abo-Historie</div>' + subRows +
'<div class="admin-section">Instanzen</div>' + instRows;
root.innerHTML = card(html, true);
root.querySelector("#aback").onclick = () => paintAdmin();
root.querySelector("#alogout").onclick = () => { adminTok.clear(); renderAdminLogin(""); };
root.querySelectorAll("[data-act]").forEach((b) =>
(b.onclick = async () => {
b.disabled = true;
try {
await adminApi("POST", "/admin/instances/" + b.dataset.iid + "/" + b.dataset.act);
renderAdminDetail(id); // neu laden
} catch (err) { alert(err.message); b.disabled = false; }
})
);
}
({ login: renderLogin, register: renderRegister, konto: renderKonto, preise: renderPreise, admin: renderAdmin }[page] || renderLogin)();
})();