9e24ce8dd6
- Login/Register: links Formular, rechts Vertrauens-Panel (Logo, 🇨🇭 Daten in der Schweiz, Backups, kündbar) → wirkt hochwertiger als nackte Box - Preise: empfohlene Karte hervorgehoben (angehoben, Akzent-Glow, Tönung) - responsive: Aside/Lift fällt unter 760/860px weg Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
381 lines
18 KiB
JavaScript
381 lines
18 KiB
JavaScript
/* 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 '<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() {
|
|
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() {
|
|
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) {
|
|
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>";
|
|
}
|
|
|
|
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.");
|
|
}
|
|
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;
|
|
}
|
|
|
|
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.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>' +
|
|
'<button class="hosting-link" id="alogout">Abmelden</button></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)();
|
|
})();
|