/* 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) =>
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
function card(inner, wide) {
return '
' + inner + "
";
}
// Auth-Karten als 2-Spalter: links Formular, rechts Vertrauens-Panel.
// (Login/Register) — wirkt deutlich hochwertiger als die nackte Box.
function authCard(formInner) {
return (
'' +
'
' + formInner + "
" +
'
' +
'
' +
'
' +
"🇨🇭 Daten in der Schweiz " +
"Tägliche Backups " +
"In Minuten startklar " +
"Monatlich kündbar " +
" " +
"
" +
"
"
);
}
function msg(el, text, kind) {
el.innerHTML = text ? '' + esc(text) + "
" : "";
}
// ── 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(
'Anmelden
' +
'Zu Ihrer Rapport-Instanz
' +
'
' +
'" +
''
), 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(
'Konto erstellen
' +
'In Minuten zur eigenen Instanz
' +
'
' +
'" +
''
), 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('Lädt…
', 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('' + esc(err.message) + "
"); return;
}
paintKonto();
}
function paintKonto() {
const { account } = acctData;
const tabs = [["overview", "Übersicht"], ["profile", "Profil"], ["security", "Sicherheit"]];
const head =
'' +
'
Mein Konto
' +
'
Abmelden ' +
'' + esc(account.email) + "
" +
'' +
tabs.map(([id, label]) =>
'' + label + " "
).join("") + "
";
const body = { overview: tabOverview, profile: tabProfile, security: tabSecurity }[acctTab]();
root.innerHTML = card(head + '' + body + "
", 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 += 'Zahlung erfolgreich — Ihre Instanz wird bereitgestellt.
';
if (subscription) {
const pastDue = subscription.status === "past_due";
if (pastDue) h += 'Letzte Zahlung fehlgeschlagen — bitte Zahlungsmittel im Abo-Portal aktualisieren.
';
h += 'Abo ' +
esc(subscription.plan) + " · " + esc(subscription.status) + "
";
if (subscription.current_period_end)
h += 'Nächste Verlängerung ' +
esc(new Date(subscription.current_period_end).toLocaleDateString("de-CH")) + "
";
h += 'Abo verwalten
';
}
if (instances && instances.length) {
h += 'Ihre Instanzen
';
h += instances.map((i) =>
'' + esc(i.label || i.studio_slug) + " " +
'
' + esc(i.instance_url) + "
" +
'
Öffnen → '
).join("");
// Weitere Instanz (Multi-Instanz vorbereitet — Backend-Sperre öffnen wir später)
h += '+ Weitere Instanz
';
} else {
h += 'Wählen Sie ein Abo, um Ihre Instanz freizuschalten:
' +
'' + (plans || []).map(planCard).join("") + "
";
}
return h;
}
// — Tab: Profil (Firma / Rechnungsadresse) —
function tabProfile() {
const a = acctData.account;
const f = (id, label, val, ph) =>
'' + label + " " +
' ';
return '
' +
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) +
'' +
'
' + f("zip", "PLZ", a.zip) + "
" +
'
' + f("city", "Ort", a.city) + "
" +
f("country", "Land", a.country || "CH") +
f("phone", "Telefon", a.phone) +
'Speichern ';
}
// — Tab: Sicherheit (Passwort) —
function tabSecurity() {
return '
' +
'Aktuelles Passwort ' +
' ' +
'Neues Passwort ' +
' ' +
'Passwort ändern ';
}
// — 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 '' +
(p.recommended ? '
Empfohlen
' : "") +
'
' + esc(p.name) + "
" +
'
CHF ' + esc(p.priceChf) + '/' + esc(p.interval) + "
" +
"
" + (p.features || []).map((f) => "" + esc(f) + " ").join("") + " " +
'
Wählen ';
}
// ── Preise (öffentlich, mit CTA in den Flow) ───────────────────────────
async function renderPreise() {
root.innerHTML = card('Lädt…
', true);
let plans = [];
try { plans = (await api("GET", "/billing/plans")).plans; } catch (_) {}
const html =
'Abo wählen
' +
'Monatlich kündbar · Preise in CHF, exkl. MwSt.
' +
'' + plans.map(planCard).join("") + "
";
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(
'Betreiber-Login
' +
'Interner Bereich
' +
(errText ? '' + esc(errText) + "
" : "") +
''
);
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('Lädt…
', 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('' + esc(err.message) + "
");
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 '' + esc(val) + "
" +
'
' + esc(label) + "
" +
(sub ? '
' + esc(sub) + "
" : "") + "
";
}
function paintAdmin() {
const { stats } = adminCache;
const planNames = Object.values(stats.byPlan || {});
const planChips = planNames.length
? '' + planNames.map((p) =>
'
' + esc(p.name) + " · " + esc(p.count) +
' (CHF ' + esc(p.revenue) + ")
"
).join("") + "
"
: "";
// 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) =>
'' +
"" + esc(a.email) + " " +
(a.company ? '' + esc(a.company) + "
" : "") + " " +
"" + (a.plan ? '' + esc(a.plan) + " " +
(a.sub_status && a.sub_status !== "active"
? ' ' + esc(a.sub_status) + " "
: "")
: '— ') + " " +
'' + esc(a.instance_count) + " " +
'' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + " " +
'› ' +
" "
).join("");
const planOptions = ['Alle Pläne ']
.concat(planNames.map((p) => '" + esc(p.name) + " "))
.join("");
const html =
'' +
'
Cockpit
' +
'
' +
'Health-Check ' +
'CSV-Export ' +
'Abmelden
' +
(stats.pastDueSubscriptions
? '⚠ ' + esc(stats.pastDueSubscriptions) +
" Abo(s) mit fehlgeschlagener Zahlung (past_due) — Kunden kontaktieren.
"
: "") +
'
' +
'' +
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)) +
"
" +
planChips +
'' +
' ' +
'' + planOptions + " " +
"
" +
'Konto Abo Inst. Seit ' +
"" + (rows || 'Keine Treffer. ') + "
";
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 = 'Prüfe Instanzen…
';
try {
const h = await adminApi("GET", "/admin/health");
if (h.mock) {
bar.innerHTML = 'Health-Check im Mock-Modus nicht aussagekräftig ' +
"(keine echten Instanzen). " + esc(h.instances.length) + " aktive Instanz(en).
";
} else if (h.down > 0) {
bar.innerHTML = '⚠ ' + esc(h.down) + " von " + esc(h.checked) +
" Instanzen nicht erreichbar (down).
";
} else {
bar.innerHTML = 'Alle ' + esc(h.checked) + " Instanzen erreichbar.
";
}
} catch (err) { bar.innerHTML = '' + esc(err.message) + "
"; }
};
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('Lädt…
', 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('' + esc(err.message) + "
"); 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]) =>
'' + esc(k) + " " + esc(v) + "
"
).join("") || 'Keine Profildaten.
';
const subRows = subscriptions.length ? subscriptions.map((s) =>
'' + esc(s.plan) + " " + esc(s.status) +
(s.priceChf != null ? ' CHF ' + esc(s.priceChf) + " " : "") + " " +
'' + esc(new Date(s.created_at).toLocaleDateString("de-CH")) + "
"
).join("") : 'Kein Abo.
';
const instRows = instances.length ? instances.map((i) =>
'' + esc(i.label || i.studio_slug) + " " +
'
' + esc(i.status) + " " +
'
' + esc(i.instance_url) + "
" +
'
' +
'
Öffnen ' +
(i.status === "active"
? '
Sperren '
: '
Reaktivieren ') +
"
"
).join("") : 'Keine Instanzen.
';
const html =
'' +
'‹ Zurück ' +
'Abmelden
' +
'' + esc(a.email) + "
" +
'Kunde seit ' +
esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "
" +
'Profil
' + profile +
'Abo-Historie
' + subRows +
'Instanzen
' + 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)();
})();