From 6a2393301dc69777f35af37cb0cec924a172535c Mon Sep 17 00:00:00 2001 From: karim Date: Sun, 31 May 2026 00:04:19 +0200 Subject: [PATCH] feat(admin): Betreiber-Panel (/api/admin) mit is_admin-Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0003_admin.sql: accounts.is_admin - auth.js: ensureAdminFlag (Konto = ADMIN_EMAIL wird auto-promoted), is_admin im JWT, requireAdmin-Middleware (prüft DB autoritativ) - routes/admin.js: GET /stats (Kunden/Abos/Instanzen/MRR), GET /accounts, GET /accounts/:id/instances, POST /instances/:id/{suspend,reactivate} - register/login + /account/me liefern is_admin - ADMIN_EMAIL in .env.example E2E: Admin-Promotion, Kunde→403, Stats (2 Kunden/MRR 49), Kundenliste. Co-Authored-By: Claude Opus 4.8 --- .env.example | 2 + server/auth.js | 34 ++++++++++++++-- server/env.js | 2 + server/index.js | 2 + server/migrations/0003_admin.sql | 5 +++ server/routes/account.js | 2 +- server/routes/admin.js | 70 ++++++++++++++++++++++++++++++++ server/routes/auth.js | 2 +- 8 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 server/migrations/0003_admin.sql create mode 100644 server/routes/admin.js diff --git a/.env.example b/.env.example index 1cae9c2..b1b061a 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ PUBLIC_BASE_URL=http://localhost:8787 # ═══ Auth ═══ # JWT-Signatur-Secret für HOST-Kundenkonten. openssl rand -hex 32 JWT_SECRET=CHANGE-ME-min-32-zeichen +# Konto mit dieser E-Mail wird automatisch Admin (Betreiber-Bereich /admin). +ADMIN_EMAIL=karim@gabrielevarano.ch # ═══ Postgres (eigene HOST-DB, GETRENNT von Kunden-Rapport-Daten) ═══ DATABASE_URL=postgres://rapport_host:rapport_host@localhost:55432/rapport_host diff --git a/server/auth.js b/server/auth.js index 66b5fe3..c27de2e 100644 --- a/server/auth.js +++ b/server/auth.js @@ -4,6 +4,7 @@ import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import { env } from "./env.js"; +import { one, query } from "./db.js"; export async function hashPassword(plain) { return bcrypt.hash(plain, 10); @@ -14,9 +15,23 @@ export async function verifyPassword(plain, hash) { } export function signToken(account) { - return jwt.sign({ sub: account.id, email: account.email }, env.jwtSecret, { - expiresIn: "7d", - }); + return jwt.sign( + { sub: account.id, email: account.email, is_admin: !!account.is_admin }, + env.jwtSecret, + { expiresIn: "7d" } + ); +} + +// Promotet das Konto zum Admin, wenn die E-Mail ADMIN_EMAIL entspricht. +// Wird bei Register/Login aufgerufen, damit dein Konto ohne manuellen DB-Eingriff +// Admin wird. Gibt das (ggf. aktualisierte) is_admin zurück. +export async function ensureAdminFlag(account) { + const shouldBeAdmin = env.adminEmail && account.email.toLowerCase() === env.adminEmail; + if (shouldBeAdmin && !account.is_admin) { + await query("update accounts set is_admin = true where id = $1", [account.id]); + return true; + } + return !!account.is_admin; } // Express-Middleware: setzt req.account aus dem Bearer-Token oder 401. @@ -26,9 +41,20 @@ export function requireAuth(req, res, next) { if (!token) return res.status(401).json({ error: "Nicht angemeldet." }); try { const payload = jwt.verify(token, env.jwtSecret); - req.account = { id: payload.sub, email: payload.email }; + req.account = { id: payload.sub, email: payload.email, is_admin: !!payload.is_admin }; next(); } catch { res.status(401).json({ error: "Session ungültig oder abgelaufen." }); } } + +// Wie requireAuth, aber verlangt Admin. Prüft die DB (autoritativ — nicht nur +// das Token), damit ein entzogenes Admin-Recht sofort greift. +export function requireAdmin(req, res, next) { + requireAuth(req, res, async () => { + const row = await one("select is_admin from accounts where id = $1", [req.account.id]); + if (!row || !row.is_admin) return res.status(403).json({ error: "Kein Admin-Zugriff." }); + req.account.is_admin = true; + next(); + }); +} diff --git a/server/env.js b/server/env.js index 49eb4fb..7aadfe8 100644 --- a/server/env.js +++ b/server/env.js @@ -26,6 +26,8 @@ export const env = { publicBaseUrl: (e.PUBLIC_BASE_URL || "http://localhost:8787").replace(/\/+$/, ""), jwtSecret: e.JWT_SECRET || "dev-insecure-secret-change-me", databaseUrl: e.DATABASE_URL || "postgres://rapport_host:rapport_host@localhost:55432/rapport_host", + // Konto mit dieser E-Mail wird automatisch zum Admin (Betreiber-Bereich /admin). + adminEmail: (e.ADMIN_EMAIL || "").trim().toLowerCase(), // Gebautes public/ der RAPPORT-WEBSITE (Hugo). Default: Schwester-Repo lokal. websitePublicDir: e.WEBSITE_PUBLIC_DIR || new URL("../../RAPPORT-WEBSITE/public", import.meta.url).pathname, diff --git a/server/index.js b/server/index.js index 8a63bfb..c1daacd 100644 --- a/server/index.js +++ b/server/index.js @@ -6,6 +6,7 @@ import { env } from "./env.js"; import { authRouter } from "./routes/auth.js"; import { billingRouter } from "./routes/billing.js"; import { accountRouter } from "./routes/account.js"; +import { adminRouter } from "./routes/admin.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -20,6 +21,7 @@ app.get("/api/health", (_req, res) => res.json({ ok: true, service: "rapport-hos app.use("/api/auth", authRouter); app.use("/api/billing", billingRouter); app.use("/api/account", accountRouter); +app.use("/api/admin", adminRouter); // ── Statische Auslieferung der RAPPORT-WEBSITE (Hugo-Build) ────────────────── // Die komplette Frontend-Oberfläche (Marketing + Hosting + Login/Konto als diff --git a/server/migrations/0003_admin.sql b/server/migrations/0003_admin.sql new file mode 100644 index 0000000..a1a283a --- /dev/null +++ b/server/migrations/0003_admin.sql @@ -0,0 +1,5 @@ +-- RAPPORT-HOST — Admin-Flag für den Betreiber-Bereich (/admin). +-- Ein Konto mit is_admin = true sieht alle Kunden, Abos, Instanzen und kann +-- Instanzen sperren/reaktivieren. Das eigene Konto wird automatisch promoted, +-- wenn die E-Mail ADMIN_EMAIL aus der .env entspricht (siehe auth.js). +alter table accounts add column if not exists is_admin boolean not null default false; diff --git a/server/routes/account.js b/server/routes/account.js index 2706b97..b28d76c 100644 --- a/server/routes/account.js +++ b/server/routes/account.js @@ -12,7 +12,7 @@ const PROFILE_FIELDS = ["company", "contact_name", "street", "zip", "city", "cou // ── Konto-Übersicht: Konto + Profil + aktuelles Abo + alle Instanzen ───────── accountRouter.get("/me", requireAuth, async (req, res) => { const account = await one( - `select id, email, company, contact_name, street, zip, city, country, phone, created_at + `select id, email, company, contact_name, street, zip, city, country, phone, is_admin, created_at from accounts where id = $1`, [req.account.id] ); diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..0713398 --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,70 @@ +// RAPPORT-HOST — Betreiber-Bereich (/api/admin). Nur für is_admin-Konten. +// Übersicht aller Kunden, Abos, Instanzen + Sperren/Reaktivieren. +import { Router } from "express"; +import { one, query } from "../db.js"; +import { requireAdmin } from "../auth.js"; +import { getPlan } from "../plans.js"; + +export const adminRouter = Router(); + +adminRouter.use(requireAdmin); + +// — Kennzahlen fürs Dashboard — +adminRouter.get("/stats", async (_req, res) => { + const accounts = await one("select count(*)::int n from accounts"); + const activeSubs = await one("select count(*)::int n from subscriptions where status = 'active'"); + const instances = await one("select count(*)::int n from instances"); + const activeInst = await one("select count(*)::int n from instances where status = 'active'"); + + // MRR (geschätzt): Summe der Plan-Preise aller aktiven Abos. + const { rows: subs } = await query("select plan from subscriptions where status = 'active'"); + const mrr = subs.reduce((sum, s) => sum + (getPlan(s.plan)?.priceChf || 0), 0); + + res.json({ + accounts: accounts.n, + activeSubscriptions: activeSubs.n, + instances: instances.n, + activeInstances: activeInst.n, + mrrChf: mrr, + }); +}); + +// — Alle Kunden mit Abo + Instanzen — +adminRouter.get("/accounts", async (_req, res) => { + const { rows } = await query(` + select a.id, a.email, a.company, a.is_admin, a.created_at, + s.plan, s.status as sub_status, s.current_period_end, + coalesce(i.cnt, 0)::int as instance_count + from accounts a + left join lateral ( + select plan, status, current_period_end + from subscriptions where account_id = a.id + order by created_at desc limit 1 + ) s on true + left join ( + select account_id, count(*) cnt from instances group by account_id + ) i on i.account_id = a.id + order by a.created_at desc + `); + res.json({ accounts: rows }); +}); + +// — Instanzen eines Kontos — +adminRouter.get("/accounts/:id/instances", async (req, res) => { + const { rows } = await query( + "select id, studio_slug, label, instance_url, status, created_at from instances where account_id = $1 order by created_at", + [req.params.id] + ); + res.json({ instances: rows }); +}); + +// — Instanz sperren / reaktivieren — +adminRouter.post("/instances/:id/:action", async (req, res) => { + const { id, action } = req.params; + const map = { suspend: "suspended", reactivate: "active" }; + const status = map[action]; + if (!status) return res.status(400).json({ error: "Unbekannte Aktion." }); + const row = await one("update instances set status = $1 where id = $2 returning id, status", [status, id]); + if (!row) return res.status(404).json({ error: "Instanz nicht gefunden." }); + res.json({ instance: row }); +}); diff --git a/server/routes/auth.js b/server/routes/auth.js index 0fa2ea2..1cd8baa 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,7 +1,7 @@ // HOST-Konten: Registrierung + Login. Gibt ein JWT zurück. import { Router } from "express"; import { one } from "../db.js"; -import { hashPassword, verifyPassword, signToken } from "../auth.js"; +import { hashPassword, verifyPassword, signToken, ensureAdminFlag } from "../auth.js"; export const authRouter = Router();