Initial: RAPPORT-HOST Iteration 1 (proprietär)
Kommerzielle Hosting-/Abo-Plattform für Rapport-Instanzen. - React-Frontend (Vite/JSX): Landing, Register, Login, Plans, Dashboard - Node/Express-Backend: Auth (bcrypt+JWT), Stripe-Billing, Provisioning - HOST-Postgres-Schema: accounts, subscriptions, instances - Provisioning-Interface + Modell-A-Adapter (Studio im geteilten Stack) - MOCK-Modus: voller End-to-End-Flow ohne Stripe/Rapport-Stack testbar - Idempotentes Fulfillment (Upsert auf stripe_subscription_id) - docker-compose für lokale host-db; identisch auf Hetzner deploybar E2E lokal verifiziert: Register -> Checkout(mock) -> Instanz -> Idempotenz. Lizenz: proprietär (kein AGPL-Code eingebunden, nur Netzwerk-API zur Familie). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# RAPPORT-HOST — Konfiguration
|
||||||
|
# Kopiere nach .env (lokal) bzw. setze die Werte in der Hetzner-Umgebung.
|
||||||
|
# .env steht in .gitignore — niemals committen.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ═══ Server ═══
|
||||||
|
PORT=8787
|
||||||
|
# Basis-URL, unter der RAPPORT-HOST erreichbar ist (für Stripe-Redirects).
|
||||||
|
# Lokal: http://localhost:5273 · Prod: https://host.rapport.studio
|
||||||
|
PUBLIC_BASE_URL=http://localhost:5273
|
||||||
|
|
||||||
|
# ═══ Auth ═══
|
||||||
|
# JWT-Signatur-Secret für HOST-Kundenkonten. openssl rand -hex 32
|
||||||
|
JWT_SECRET=CHANGE-ME-min-32-zeichen
|
||||||
|
|
||||||
|
# ═══ Postgres (eigene HOST-DB, GETRENNT von Kunden-Rapport-Daten) ═══
|
||||||
|
DATABASE_URL=postgres://rapport_host:rapport_host@localhost:55432/rapport_host
|
||||||
|
|
||||||
|
# ═══ Stripe ═══
|
||||||
|
# Test-Mode-Keys (sk_test_… / pk_test_…) für lokale Entwicklung.
|
||||||
|
# Live-Keys (sk_live_…) erst in Produktion. Test-Karten: 4242 4242 4242 4242
|
||||||
|
STRIPE_SECRET_KEY=sk_test_CHANGE-ME
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_CHANGE-ME
|
||||||
|
# Price-IDs aus dem Stripe-Dashboard (ein Recurring-Price pro Plan).
|
||||||
|
STRIPE_PRICE_SOLO=price_CHANGE-ME
|
||||||
|
STRIPE_PRICE_STUDIO=price_CHANGE-ME
|
||||||
|
STRIPE_PRICE_BUSINESS=price_CHANGE-ME
|
||||||
|
|
||||||
|
# ═══ Provisioning (Modell A: geteilter Rapport-Stack) ═══
|
||||||
|
# Wenn LEER: MOCK-Modus — Instanz wird nur als DB-Eintrag simuliert, der ganze
|
||||||
|
# Flow ist ohne laufenden Rapport-Stack testbar.
|
||||||
|
# Wenn gesetzt: echtes Provisioning gegen den geteilten Rapport-Stack.
|
||||||
|
RAPPORT_API_URL=
|
||||||
|
RAPPORT_SERVICE_KEY=
|
||||||
|
# URL-Template für die fertige Kunden-Instanz. {slug} wird ersetzt.
|
||||||
|
RAPPORT_INSTANCE_URL_TEMPLATE=http://localhost:8080/?studio={slug}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
server/node_modules/
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
# RAPPORT-HOST — Architektur
|
||||||
|
|
||||||
|
## 1 · Mentales Modell
|
||||||
|
|
||||||
|
RAPPORT-HOST ist die **kommerzielle Schicht** über der Rapport-Familie. Es
|
||||||
|
verkauft und provisioniert Rapport-Instanzen, berührt aber die Tenant-Daten der
|
||||||
|
Kunden nicht direkt — es spricht mit dem Rapport-Stack nur über dessen API.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──/api──▶ Node/Express (server/) ──▶ HOST-Postgres (Konten/Abos/Instanzen)
|
||||||
|
│ │
|
||||||
|
│ ├──▶ Stripe (Checkout + Webhook)
|
||||||
|
│ └──▶ Rapport-Stack-API (Provisioning, service_role)
|
||||||
|
└── React (src/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2 · Lizenz-Abgrenzung (wichtig)
|
||||||
|
|
||||||
|
RAPPORT-HOST ist **proprietär**. Die übrigen Repos sind **AGPL-3.0**. Damit die
|
||||||
|
AGPL nicht auf HOST „überspringt", gilt eine harte Regel: **kein AGPL-Code wird
|
||||||
|
importiert oder kopiert.** Die Kommunikation läuft ausschließlich über
|
||||||
|
Netzwerk-APIs (HTTP/REST). So bleibt HOST ein eigenständiges Werk.
|
||||||
|
|
||||||
|
## 3 · Verzeichnis-Karte
|
||||||
|
|
||||||
|
```
|
||||||
|
RAPPORT-HOST/
|
||||||
|
├── src/ React-Frontend (JSX, kein TS — wie APP)
|
||||||
|
│ ├── App.jsx Mini-Pfad-Router
|
||||||
|
│ ├── api.js fetch-Client + Token-Handling
|
||||||
|
│ └── views/ Landing, Register, Login, Plans, Dashboard
|
||||||
|
├── server/ Node/Express-Backend
|
||||||
|
│ ├── index.js Entry; Webhook-Raw-Body vor json()
|
||||||
|
│ ├── env.js Env laden + MOCK-Flags
|
||||||
|
│ ├── db.js pg-Pool (HOST-DB)
|
||||||
|
│ ├── auth.js bcrypt + JWT + requireAuth
|
||||||
|
│ ├── plans.js Plan-Definitionen (Preise, Limits, Stripe-Price)
|
||||||
|
│ ├── stripe.js Stripe-Client (null im Mock)
|
||||||
|
│ ├── routes/ auth · billing · account
|
||||||
|
│ ├── provisioning/ index (Interface) + studio-adapter (Modell A)
|
||||||
|
│ ├── migrations/0001_init.sql HOST-Schema
|
||||||
|
│ └── migrate.js Migrations-Runner
|
||||||
|
├── docker-compose.yml host-db (+ app für Prod, auskommentiert)
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4 · Datenmodell (HOST-DB)
|
||||||
|
|
||||||
|
- **accounts** — zahlende Büros (Email + bcrypt-Hash). NICHT die Endnutzer.
|
||||||
|
- **subscriptions** — Plan, Status, Stripe-IDs, Periodenende. `stripe_subscription_id`
|
||||||
|
ist der Idempotenz-Key fürs Fulfillment.
|
||||||
|
- **instances** — provisionierte Rapport-Instanzen (Modell A: `studio_id` +
|
||||||
|
`studio_slug` im geteilten Stack, `instance_url`, Status).
|
||||||
|
|
||||||
|
Bewusst **getrennt** von der Rapport-/Kunden-Datenbank.
|
||||||
|
|
||||||
|
## 5 · Zahlungs-/Provisioning-Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Plan wählen ─▶ POST /api/billing/checkout
|
||||||
|
│
|
||||||
|
Stripe? ──┤── nein (MOCK): fulfill() sofort ─▶ Erfolgs-URL
|
||||||
|
│── ja: Stripe-Checkout-Session ─▶ Redirect zu Stripe
|
||||||
|
│
|
||||||
|
Stripe ─POST /api/billing/webhook (checkout.session.completed)
|
||||||
|
│
|
||||||
|
fulfill()
|
||||||
|
├─ subscriptions upsert
|
||||||
|
└─ provision() ─▶ instances insert
|
||||||
|
```
|
||||||
|
|
||||||
|
`fulfill()` ist idempotent (Upsert auf `stripe_subscription_id`, Instanz nur wenn
|
||||||
|
noch keine existiert) — Stripe kann Webhooks mehrfach senden.
|
||||||
|
|
||||||
|
## 6 · Provisioning-Modelle
|
||||||
|
|
||||||
|
Hinter dem Interface `provisioning/index.js`:
|
||||||
|
|
||||||
|
- **Modell A (jetzt, `studio-adapter.js`)**: ein isoliertes Studio im geteilten
|
||||||
|
Rapport-Stack. Vermarktet als „eigene Instanz", technisch RLS-Mandant.
|
||||||
|
Millisekunden, solo-betreibbar.
|
||||||
|
- Echtes Provisioning braucht im Rapport-Schema noch ein server-seitiges
|
||||||
|
RPC `create_studio_for_user(p_user_id, p_name, p_slug)` (da
|
||||||
|
`create_studio_with_admin` auf `auth.uid()` baut). Bis dahin: MOCK-Modus.
|
||||||
|
- **Modell B (später)**: eigener Container/Stack pro Kunde (volle Isolation,
|
||||||
|
„Dedicated"-Tier). Neuer Adapter mit identischer Signatur — hier käme
|
||||||
|
`SERVER-PROXMOX-LXC` als Provisioning-Backend ins Spiel.
|
||||||
|
|
||||||
|
## 7 · MOCK-Modus
|
||||||
|
|
||||||
|
Zwei unabhängige Schalter (in `env.js`):
|
||||||
|
|
||||||
|
- `stripeEnabled` — false ohne `sk_…`-Key ⇒ Checkout schaltet sofort frei.
|
||||||
|
- `provisioningMock` — true ohne `RAPPORT_API_URL`/`SERVICE_KEY` ⇒ Instanz wird
|
||||||
|
nur als DB-Eintrag mit synthetischer `studioId` simuliert.
|
||||||
|
|
||||||
|
So ist der komplette Flow lokal testbar, bevor Stripe-Account und Rapport-Stack
|
||||||
|
stehen. In Produktion sind beide Schalter aus.
|
||||||
|
|
||||||
|
## 8 · Roadmap
|
||||||
|
|
||||||
|
- [ ] Iteration 1: Register/Login, Stripe-Checkout, Webhook, Dashboard (MOCK-fähig)
|
||||||
|
- [ ] `create_studio_for_user`-RPC im Rapport-Schema → echtes Modell-A-Provisioning
|
||||||
|
- [ ] Stripe Customer-Portal (Abo verwalten/kündigen)
|
||||||
|
- [ ] Super-Admin (Kundenübersicht, Umsatz, sperren)
|
||||||
|
- [ ] QR-Rechnung für Geschäftskunden
|
||||||
|
- [ ] Modell B: Container-Provisioning-Adapter
|
||||||
|
- [ ] Deploy auf Hetzner
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
RAPPORT-HOST — Proprietäre Software
|
||||||
|
Copyright (C) 2026 Karim Gabriele Varano. Alle Rechte vorbehalten.
|
||||||
|
|
||||||
|
Dies ist KEINE Open-Source-Software. Im Gegensatz zu den übrigen Komponenten
|
||||||
|
der Rapport-Familie (RAPPORT-App, RAPPORT-Server — beide AGPL-3.0) ist
|
||||||
|
RAPPORT-HOST proprietär und kommerziell.
|
||||||
|
|
||||||
|
Kein Teil dieser Software darf ohne ausdrückliche schriftliche Genehmigung des
|
||||||
|
Rechteinhabers kopiert, verändert, weitergegeben, unterlizenziert, öffentlich
|
||||||
|
zugänglich gemacht oder für Dritte betrieben werden.
|
||||||
|
|
||||||
|
Wichtiger Lizenz-Hinweis (AGPL-Abgrenzung):
|
||||||
|
RAPPORT-HOST kommuniziert mit den AGPL-Komponenten (RAPPORT-App, RAPPORT-Server)
|
||||||
|
AUSSCHLIESSLICH über deren Netzwerk-APIs (HTTP/REST). Es bindet keinen
|
||||||
|
AGPL-lizenzierten Quellcode ein und leitet sich nicht davon ab. Dadurch bleibt
|
||||||
|
RAPPORT-HOST eine eigenständige, proprietäre Anwendung und unterliegt nicht der
|
||||||
|
AGPL-3.0. Diese Trennung ist bewusst und MUSS erhalten bleiben: niemals
|
||||||
|
AGPL-Code direkt in dieses Repo kopieren.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# RAPPORT-HOST
|
||||||
|
|
||||||
|
> ⚠️ **Proprietär & kommerziell** — im Gegensatz zur restlichen Rapport-Familie (AGPL). Siehe [LICENSE](LICENSE). Niemals AGPL-Code direkt einbinden.
|
||||||
|
|
||||||
|
Die Hosting- und Abo-Plattform für Rapport. Kunden registrieren sich, wählen ein Abo (Stripe), und bekommen automatisch eine eigene Rapport-Instanz freigeschaltet.
|
||||||
|
|
||||||
|
Teil der Rapport-Familie:
|
||||||
|
|
||||||
|
| Repo | Rolle | Lizenz |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP` | Desktop-/Web-Client | AGPL |
|
||||||
|
| `SERVER-CONTAINER` | Self-Host Docker-Stack | AGPL |
|
||||||
|
| `SERVER-APP` | Mac-Doppelklick-Server | AGPL |
|
||||||
|
| `SERVER-PROXMOX-LXC` | Proxmox-Installer | AGPL |
|
||||||
|
| **`RAPPORT-HOST`** | **Kommerzielle Hosting-Plattform** | **Proprietär** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status: Iteration 1
|
||||||
|
|
||||||
|
End-to-End-Flow lokal testbar (auch **ohne** Stripe/Rapport-Stack dank MOCK-Modus):
|
||||||
|
|
||||||
|
1. Registrierung + Login (HOST-Konten, JWT)
|
||||||
|
2. Abo-Plan wählen → Stripe-Checkout (oder Mock-Sofortfreischaltung)
|
||||||
|
3. Webhook → Instanz provisionieren (Modell A: Studio im geteilten Stack)
|
||||||
|
4. Kunden-Dashboard mit Abo-Status + Link zur Instanz
|
||||||
|
|
||||||
|
Noch offen: Super-Admin, QR-Rechnung, echtes Rapport-Provisioning-RPC, Modell B (eigener Container).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur in einem Satz
|
||||||
|
|
||||||
|
React-Frontend (`src/`) spricht über `/api` mit einem Node/Express-Backend (`server/`), das HOST-Konten + Abos in einer **eigenen** Postgres hält und bei bezahltem Abo eine Rapport-Instanz provisioniert. Details: [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lokal starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. HOST-Datenbank (Container)
|
||||||
|
docker compose up -d host-db
|
||||||
|
|
||||||
|
# 2. Backend-Dependencies + Schema
|
||||||
|
cd server && npm install && cd ..
|
||||||
|
npm run server:migrate
|
||||||
|
|
||||||
|
# 3. .env anlegen
|
||||||
|
cp .env.example .env # Defaults reichen für den MOCK-Modus
|
||||||
|
|
||||||
|
# 4. Frontend-Dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 5. In zwei Terminals:
|
||||||
|
npm run server # Backend → :8787
|
||||||
|
npm run dev # Frontend → :5273
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne http://localhost:5273 → registrieren → Plan wählen. Ohne Stripe-Keys
|
||||||
|
schaltet der MOCK-Modus die Instanz sofort frei, und das Dashboard zeigt den
|
||||||
|
(simulierten) Instanz-Link.
|
||||||
|
|
||||||
|
### Mit echtem Stripe (Test-Mode)
|
||||||
|
|
||||||
|
`.env` mit `STRIPE_SECRET_KEY=sk_test_…` + `STRIPE_PRICE_*` füllen. Webhook lokal
|
||||||
|
weiterleiten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stripe listen --forward-to localhost:8787/api/billing/webhook
|
||||||
|
# das ausgegebene whsec_… in STRIPE_WEBHOOK_SECRET eintragen
|
||||||
|
```
|
||||||
|
|
||||||
|
Test-Karte: `4242 4242 4242 4242`, beliebiges Zukunftsdatum + CVC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy (Hetzner, später)
|
||||||
|
|
||||||
|
Identischer Code — nur `.env` mit Live-Werten (`sk_live_…`, echte
|
||||||
|
`DATABASE_URL`, `PUBLIC_BASE_URL=https://…`, `RAPPORT_API_URL`/`SERVICE_KEY`
|
||||||
|
für echtes Provisioning). Das `app`-Image in `docker-compose.yml` ist dafür
|
||||||
|
vorbereitet (auskommentiert).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# RAPPORT-HOST — lokaler Stack (identisch deploybar auf Hetzner via .env).
|
||||||
|
# Nur die HOST-eigene Postgres läuft hier als Container; das Backend/Frontend
|
||||||
|
# fährst du im Dev mit `npm run server` + `npm run dev`. Für Prod ist unten ein
|
||||||
|
# (auskommentiertes) app-Image vorbereitet.
|
||||||
|
|
||||||
|
services:
|
||||||
|
host-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: rapport-host-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: rapport_host
|
||||||
|
POSTGRES_PASSWORD: rapport_host
|
||||||
|
POSTGRES_DB: rapport_host
|
||||||
|
ports:
|
||||||
|
- "55432:5432" # 55432 lokal, um Konflikte mit anderen PG zu vermeiden
|
||||||
|
volumes:
|
||||||
|
- host-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "rapport_host"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# ── Prod (später): Backend+Frontend als ein Image ─────────────────────────
|
||||||
|
# app:
|
||||||
|
# build: .
|
||||||
|
# environment:
|
||||||
|
# DATABASE_URL: postgres://rapport_host:rapport_host@host-db:5432/rapport_host
|
||||||
|
# # restliche Env aus .env / Hetzner-Secrets
|
||||||
|
# depends_on:
|
||||||
|
# host-db:
|
||||||
|
# condition: service_healthy
|
||||||
|
# ports:
|
||||||
|
# - "8787:8787"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
host-db-data:
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RAPPORT — Hosting für Architekturbüros</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1719
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "rapport-host",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "RAPPORT-HOST — kommerzielle Hosting-/Abo-Plattform für Rapport-Instanzen (proprietär)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"server": "node --watch server/index.js",
|
||||||
|
"server:migrate": "node server/migrate.js",
|
||||||
|
"dev:all": "echo 'In zwei Terminals: npm run server und npm run dev'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// HOST-Kundenkonten: Passwort-Hashing + JWT-Ausstellung/-Prüfung.
|
||||||
|
// (Das sind die Konten auf der RAPPORT-HOST-Plattform — NICHT die Endnutzer
|
||||||
|
// in den einzelnen Rapport-Instanzen.)
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
|
export async function hashPassword(plain) {
|
||||||
|
return bcrypt.hash(plain, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plain, hash) {
|
||||||
|
return bcrypt.compare(plain, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signToken(account) {
|
||||||
|
return jwt.sign({ sub: account.id, email: account.email }, env.jwtSecret, {
|
||||||
|
expiresIn: "7d",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express-Middleware: setzt req.account aus dem Bearer-Token oder 401.
|
||||||
|
export function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || "";
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||||
|
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 };
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: "Session ungültig oder abgelaufen." });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Postgres-Pool für die HOST-eigene Datenbank (Konten, Abos, Instanzen).
|
||||||
|
// Bewusst getrennt von den Kunden-Rapport-Daten.
|
||||||
|
import pg from "pg";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
|
export const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||||
|
|
||||||
|
export async function query(text, params) {
|
||||||
|
return pool.query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kleiner Helfer: erste Zeile oder null.
|
||||||
|
export async function one(text, params) {
|
||||||
|
const { rows } = await pool.query(text, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Zentrales Laden + Validieren der Umgebungsvariablen.
|
||||||
|
// Liest .env (einfacher Parser, keine Dependency) und fällt sonst auf
|
||||||
|
// process.env zurück — so läuft es lokal mit Datei und auf Hetzner mit
|
||||||
|
// echten Env-Vars, ohne Code-Änderung.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const envPath = path.resolve(__dirname, "..", ".env");
|
||||||
|
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const key = m[1];
|
||||||
|
let val = m[2].replace(/^["']|["']$/g, "");
|
||||||
|
if (process.env[key] === undefined) process.env[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = process.env;
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
port: parseInt(e.PORT || "8787", 10),
|
||||||
|
publicBaseUrl: (e.PUBLIC_BASE_URL || "http://localhost:5273").replace(/\/+$/, ""),
|
||||||
|
jwtSecret: e.JWT_SECRET || "dev-insecure-secret-change-me",
|
||||||
|
databaseUrl: e.DATABASE_URL || "postgres://rapport_host:rapport_host@localhost:55432/rapport_host",
|
||||||
|
|
||||||
|
stripe: {
|
||||||
|
secretKey: e.STRIPE_SECRET_KEY || "",
|
||||||
|
webhookSecret: e.STRIPE_WEBHOOK_SECRET || "",
|
||||||
|
prices: {
|
||||||
|
solo: e.STRIPE_PRICE_SOLO || "",
|
||||||
|
studio: e.STRIPE_PRICE_STUDIO || "",
|
||||||
|
business: e.STRIPE_PRICE_BUSINESS || "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rapport: {
|
||||||
|
apiUrl: e.RAPPORT_API_URL || "",
|
||||||
|
serviceKey: e.RAPPORT_SERVICE_KEY || "",
|
||||||
|
instanceUrlTemplate: e.RAPPORT_INSTANCE_URL_TEMPLATE || "http://localhost:8080/?studio={slug}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// MOCK-Modus: ohne echte Stripe-/Rapport-Keys läuft alles lokal simuliert,
|
||||||
|
// damit der End-to-End-Flow ohne externe Dienste testbar ist.
|
||||||
|
export const stripeEnabled = !!env.stripe.secretKey && env.stripe.secretKey.startsWith("sk_");
|
||||||
|
export const provisioningMock = !env.rapport.apiUrl || !env.rapport.serviceKey;
|
||||||
|
|
||||||
|
if (env.jwtSecret === "dev-insecure-secret-change-me") {
|
||||||
|
console.warn("⚠ JWT_SECRET nicht gesetzt — unsicheres Dev-Secret in Verwendung.");
|
||||||
|
}
|
||||||
|
if (!stripeEnabled) console.warn("⚠ STRIPE_SECRET_KEY fehlt — Billing läuft im MOCK-Modus.");
|
||||||
|
if (provisioningMock) console.warn("⚠ RAPPORT_API_URL/SERVICE_KEY fehlen — Provisioning läuft im MOCK-Modus.");
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// RAPPORT-HOST Backend-Entry.
|
||||||
|
import express from "express";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { authRouter } from "./routes/auth.js";
|
||||||
|
import { billingRouter } from "./routes/billing.js";
|
||||||
|
import { accountRouter } from "./routes/account.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// WICHTIG: Der Stripe-Webhook braucht den ROHEN Body für die Signaturprüfung.
|
||||||
|
// express.raw greift nur für diese Route und setzt req._body, sodass das danach
|
||||||
|
// registrierte express.json() den Webhook-Body NICHT erneut parst.
|
||||||
|
app.use("/api/billing/webhook", express.raw({ type: "application/json" }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => res.json({ ok: true, service: "rapport-host" }));
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/billing", billingRouter);
|
||||||
|
app.use("/api/account", accountRouter);
|
||||||
|
|
||||||
|
// In Produktion liefert dasselbe Backend das gebaute Frontend aus (dist/).
|
||||||
|
const dist = path.resolve(__dirname, "..", "dist");
|
||||||
|
app.use(express.static(dist));
|
||||||
|
app.get("*", (req, res, next) => {
|
||||||
|
if (req.path.startsWith("/api/")) return next();
|
||||||
|
res.sendFile(path.join(dist, "index.html"), (err) => err && next());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(env.port, () => {
|
||||||
|
console.log(`RAPPORT-HOST API läuft auf :${env.port} (Base: ${env.publicBaseUrl})`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Einfacher Migrations-Runner: spielt alle server/migrations/*.sql in
|
||||||
|
// Sortier-Reihenfolge ein. Idempotent (alle Migrations nutzen IF NOT EXISTS).
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { pool } from "./db.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dir = path.join(__dirname, "migrations");
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
||||||
|
console.log(`→ ${files.length} Migration(en) gefunden.`);
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const sql = fs.readFileSync(path.join(dir, f), "utf8");
|
||||||
|
process.stdout.write(` ▸ ${f} … `);
|
||||||
|
await pool.query(sql);
|
||||||
|
console.log("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✓ HOST-Schema bereit.");
|
||||||
|
await pool.end();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- RAPPORT-HOST — Initiales Schema (HOST-eigene DB, getrennt von Kundendaten).
|
||||||
|
|
||||||
|
create extension if not exists "pgcrypto";
|
||||||
|
|
||||||
|
-- HOST-Kundenkonten (die zahlenden Büros, NICHT deren Endnutzer).
|
||||||
|
create table if not exists accounts (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text unique not null,
|
||||||
|
password_hash text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Abos. Ein Konto kann über die Zeit mehrere haben (Upgrade/Downgrade/Re-Sub).
|
||||||
|
create table if not exists subscriptions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
account_id uuid not null references accounts(id) on delete cascade,
|
||||||
|
plan text not null, -- solo | studio | business
|
||||||
|
status text not null, -- active | past_due | canceled | ...
|
||||||
|
stripe_customer_id text,
|
||||||
|
stripe_subscription_id text unique, -- idempotenz-key fürs Fulfillment
|
||||||
|
current_period_end timestamptz,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bereitgestellte Rapport-Instanzen (Modell A: ein Studio im geteilten Stack).
|
||||||
|
create table if not exists instances (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
account_id uuid not null references accounts(id) on delete cascade,
|
||||||
|
studio_id uuid, -- studio_id im Rapport-Stack
|
||||||
|
studio_slug text not null,
|
||||||
|
instance_url text not null,
|
||||||
|
status text not null default 'active', -- active | suspended
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_subscriptions_account on subscriptions(account_id);
|
||||||
|
create index if not exists idx_instances_account on instances(account_id);
|
||||||
Generated
+1129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "rapport-host-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "RAPPORT-HOST Backend — Auth, Stripe-Billing, Instanz-Provisioning",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"stripe": "^17.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Abo-Pläne — eine Quelle der Wahrheit für Frontend (Preisanzeige) und Backend
|
||||||
|
// (Stripe-Price-Mapping + Instanz-Limits beim Provisioning).
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
|
export const PLANS = [
|
||||||
|
{
|
||||||
|
id: "solo",
|
||||||
|
name: "Solo",
|
||||||
|
priceChf: 19,
|
||||||
|
interval: "Monat",
|
||||||
|
stripePriceId: env.stripe.prices.solo,
|
||||||
|
limits: { users: 1, projects: 25, storageGb: 2 },
|
||||||
|
features: ["1 Benutzer", "25 Projekte", "2 GB Speicher", "Eigene Instanz"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "studio",
|
||||||
|
name: "Studio",
|
||||||
|
priceChf: 49,
|
||||||
|
interval: "Monat",
|
||||||
|
stripePriceId: env.stripe.prices.studio,
|
||||||
|
limits: { users: 5, projects: 200, storageGb: 20 },
|
||||||
|
features: ["Bis 5 Benutzer", "200 Projekte", "20 GB Speicher", "Eigene Instanz", "Support"],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "business",
|
||||||
|
name: "Business",
|
||||||
|
priceChf: 99,
|
||||||
|
interval: "Monat",
|
||||||
|
stripePriceId: env.stripe.prices.business,
|
||||||
|
limits: { users: 20, projects: 1000, storageGb: 100 },
|
||||||
|
features: ["Bis 20 Benutzer", "1000 Projekte", "100 GB Speicher", "Eigene Instanz", "Prioritäts-Support"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPlan(id) {
|
||||||
|
return PLANS.find((p) => p.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fürs Frontend: ohne interne Felder (Stripe-IDs müssen nicht raus).
|
||||||
|
export function publicPlans() {
|
||||||
|
return PLANS.map(({ stripePriceId, ...rest }) => rest);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Provisioning-Interface — entkoppelt "Abo bezahlt" von "wie entsteht die Instanz".
|
||||||
|
//
|
||||||
|
// Modell A (jetzt): ein Studio im geteilten Rapport-Stack -> studio-adapter.js
|
||||||
|
// Modell B (später): eigener Container pro Kunde -> container-adapter.js
|
||||||
|
//
|
||||||
|
// Beide implementieren dieselbe Signatur:
|
||||||
|
// provision({ account, plan }) -> { studioId, slug, instanceUrl }
|
||||||
|
// deprovision({ instance }) -> void
|
||||||
|
//
|
||||||
|
// Der Aufrufer (Billing-Webhook) kennt nur dieses Interface, nie die Details.
|
||||||
|
import * as studioAdapter from "./studio-adapter.js";
|
||||||
|
|
||||||
|
const adapter = studioAdapter; // Modell A. Später per Env umschaltbar.
|
||||||
|
|
||||||
|
export function provision(args) {
|
||||||
|
return adapter.provision(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deprovision(args) {
|
||||||
|
return adapter.deprovision(args);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Modell-A-Provisioning: legt für einen zahlenden HOST-Kunden ein isoliertes
|
||||||
|
// Studio im GETEILTEN Rapport-Stack an. Vermarktet als "eigene Instanz",
|
||||||
|
// technisch ein mandantengetrenntes Studio (RLS).
|
||||||
|
//
|
||||||
|
// Ablauf gegen den Rapport-Stack (echtes Provisioning):
|
||||||
|
// 1. GoTrue-Admin-API (service_role): Auth-User für die Kunden-Email anlegen
|
||||||
|
// 2. RPC ensure_profile: Profil füllen
|
||||||
|
// 3. RPC create_studio_with_admin: Studio anlegen, User = Admin
|
||||||
|
// 4. Instanz-URL aus Template bauen
|
||||||
|
//
|
||||||
|
// MOCK-Modus (provisioningMock): ohne RAPPORT_API_URL/SERVICE_KEY wird nur eine
|
||||||
|
// synthetische studioId + slug erzeugt, damit der gesamte HOST-Flow lokal ohne
|
||||||
|
// laufenden Rapport-Stack durchgetestet werden kann.
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { env, provisioningMock } from "../env.js";
|
||||||
|
|
||||||
|
function makeSlug(seed) {
|
||||||
|
const base = (seed || "studio").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
||||||
|
return `${base || "studio"}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function instanceUrl(slug) {
|
||||||
|
return env.rapport.instanceUrlTemplate.replace("{slug}", encodeURIComponent(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provision({ account, plan }) {
|
||||||
|
const slug = makeSlug(account.email.split("@")[0]);
|
||||||
|
|
||||||
|
if (provisioningMock) {
|
||||||
|
const studioId = randomUUID();
|
||||||
|
console.log(`[provision:MOCK] Studio '${slug}' (${studioId}) für ${account.email}, Plan ${plan.id}`);
|
||||||
|
return { studioId, slug, instanceUrl: instanceUrl(slug) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Echtes Provisioning gegen den geteilten Rapport-Stack ──────────────────
|
||||||
|
// Hinweis: nutzt service_role (RAPPORT_SERVICE_KEY) — niemals ins Frontend!
|
||||||
|
const base = env.rapport.apiUrl.replace(/\/+$/, "");
|
||||||
|
const headers = {
|
||||||
|
apikey: env.rapport.serviceKey,
|
||||||
|
Authorization: `Bearer ${env.rapport.serviceKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Auth-User anlegen (GoTrue Admin-API), bereits bestätigt.
|
||||||
|
const tempPassword = randomUUID();
|
||||||
|
const userRes = await fetch(`${base}/auth/v1/admin/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ email: account.email, password: tempPassword, email_confirm: true }),
|
||||||
|
});
|
||||||
|
if (!userRes.ok) throw new Error(`GoTrue admin/users: ${userRes.status} ${await userRes.text()}`);
|
||||||
|
const user = await userRes.json();
|
||||||
|
|
||||||
|
// 2.+3. Profil + Studio per RPC. Da create_studio_with_admin auth.uid() nutzt,
|
||||||
|
// muss der Aufruf im Kontext des neuen Users laufen — hier vereinfacht über
|
||||||
|
// einen service_role-RPC, der die Ziel-User-ID als Argument nimmt. Diese
|
||||||
|
// server-seitige Variante (create_studio_for_user) ist im Rapport-Schema noch
|
||||||
|
// anzulegen; bis dahin schützt der MOCK-Modus den lokalen Test.
|
||||||
|
const slugForStudio = slug;
|
||||||
|
const rpcRes = await fetch(`${base}/rest/v1/rpc/create_studio_for_user`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ p_user_id: user.id, p_name: account.email.split("@")[0], p_slug: slugForStudio }),
|
||||||
|
});
|
||||||
|
if (!rpcRes.ok) throw new Error(`create_studio_for_user: ${rpcRes.status} ${await rpcRes.text()}`);
|
||||||
|
const studioId = (await rpcRes.json());
|
||||||
|
|
||||||
|
return { studioId, slug: slugForStudio, instanceUrl: instanceUrl(slugForStudio) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deprovision({ instance }) {
|
||||||
|
if (provisioningMock) {
|
||||||
|
console.log(`[deprovision:MOCK] Studio ${instance.studio_slug} deaktiviert.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Echtes Deprovisioning (Studio sperren statt löschen — Daten erhalten für
|
||||||
|
// Reaktivierung/Export) ist im Rapport-Schema noch zu definieren.
|
||||||
|
console.warn(`[deprovision] noch nicht implementiert für ${instance.studio_slug}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Kunden-Dashboard-Daten: Konto + aktuelles Abo + bereitgestellte Instanz.
|
||||||
|
import { Router } from "express";
|
||||||
|
import { one } from "../db.js";
|
||||||
|
import { requireAuth } from "../auth.js";
|
||||||
|
import { publicPlans } from "../plans.js";
|
||||||
|
|
||||||
|
export const accountRouter = Router();
|
||||||
|
|
||||||
|
accountRouter.get("/me", requireAuth, async (req, res) => {
|
||||||
|
const subscription = await one(
|
||||||
|
`select plan, status, current_period_end, stripe_subscription_id
|
||||||
|
from subscriptions where account_id = $1 order by created_at desc limit 1`,
|
||||||
|
[req.account.id]
|
||||||
|
);
|
||||||
|
const instance = await one(
|
||||||
|
`select studio_slug, instance_url, status, created_at
|
||||||
|
from instances where account_id = $1 order by created_at desc limit 1`,
|
||||||
|
[req.account.id]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
account: req.account,
|
||||||
|
subscription: subscription || null,
|
||||||
|
instance: instance || null,
|
||||||
|
plans: publicPlans(),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const isEmail = (s) => /.+@.+\..+/.test(s || "");
|
||||||
|
|
||||||
|
authRouter.post("/register", async (req, res) => {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
if (!isEmail(email)) return res.status(400).json({ error: "Ungültige Email." });
|
||||||
|
if (!password || password.length < 8) return res.status(400).json({ error: "Passwort min. 8 Zeichen." });
|
||||||
|
|
||||||
|
const existing = await one("select id from accounts where email = $1", [email.toLowerCase()]);
|
||||||
|
if (existing) return res.status(409).json({ error: "Konto existiert bereits." });
|
||||||
|
|
||||||
|
const account = await one(
|
||||||
|
"insert into accounts (email, password_hash) values ($1, $2) returning id, email",
|
||||||
|
[email.toLowerCase(), await hashPassword(password)]
|
||||||
|
);
|
||||||
|
res.json({ token: signToken(account), account: { id: account.id, email: account.email } });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/login", async (req, res) => {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
const account = await one("select id, email, password_hash from accounts where email = $1", [
|
||||||
|
(email || "").toLowerCase(),
|
||||||
|
]);
|
||||||
|
if (!account || !(await verifyPassword(password || "", account.password_hash))) {
|
||||||
|
return res.status(401).json({ error: "Email oder Passwort falsch." });
|
||||||
|
}
|
||||||
|
res.json({ token: signToken(account), account: { id: account.id, email: account.email } });
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Billing: Plan wählen → Stripe-Checkout → (Webhook) → Instanz freischalten.
|
||||||
|
//
|
||||||
|
// Stripe-Modus: /checkout erstellt eine Checkout-Session, Stripe leitet den
|
||||||
|
// Kunden weiter; nach Zahlung ruft Stripe /webhook auf, das die
|
||||||
|
// Instanz provisioniert.
|
||||||
|
// MOCK-Modus: ohne Stripe-Key provisioniert /checkout sofort selbst (so ist
|
||||||
|
// der ganze Flow lokal ohne Stripe testbar) und gibt eine
|
||||||
|
// interne Erfolgs-URL zurück.
|
||||||
|
import { Router } from "express";
|
||||||
|
import { one, query } from "../db.js";
|
||||||
|
import { requireAuth } from "../auth.js";
|
||||||
|
import { getPlan, publicPlans } from "../plans.js";
|
||||||
|
import { env, stripeEnabled } from "../env.js";
|
||||||
|
import { stripe } from "../stripe.js";
|
||||||
|
import { provision } from "../provisioning/index.js";
|
||||||
|
|
||||||
|
export const billingRouter = Router();
|
||||||
|
|
||||||
|
billingRouter.get("/plans", (_req, res) => res.json({ plans: publicPlans() }));
|
||||||
|
|
||||||
|
// Gemeinsame Fulfillment-Logik: Abo speichern + Instanz provisionieren.
|
||||||
|
// Idempotent über stripe_subscription_id (bzw. Mock-Kennung).
|
||||||
|
async function fulfill({ accountId, plan, stripeCustomerId, stripeSubscriptionId, status, periodEnd }) {
|
||||||
|
const account = await one("select id, email from accounts where id = $1", [accountId]);
|
||||||
|
if (!account) throw new Error("Konto nicht gefunden: " + accountId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`insert into subscriptions
|
||||||
|
(account_id, plan, status, stripe_customer_id, stripe_subscription_id, current_period_end)
|
||||||
|
values ($1,$2,$3,$4,$5,$6)
|
||||||
|
on conflict (stripe_subscription_id) do update
|
||||||
|
set status = excluded.status, current_period_end = excluded.current_period_end`,
|
||||||
|
[accountId, plan.id, status, stripeCustomerId, stripeSubscriptionId, periodEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nur provisionieren, wenn für dieses Konto noch keine aktive Instanz da ist.
|
||||||
|
const existing = await one("select id from instances where account_id = $1 limit 1", [accountId]);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const result = await provision({ account, plan });
|
||||||
|
await query(
|
||||||
|
`insert into instances (account_id, studio_id, studio_slug, instance_url, status)
|
||||||
|
values ($1,$2,$3,$4,'active')`,
|
||||||
|
[accountId, result.studioId, result.slug, result.instanceUrl]
|
||||||
|
);
|
||||||
|
console.log(`✓ Instanz freigeschaltet für ${account.email}: ${result.instanceUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
billingRouter.post("/checkout", requireAuth, async (req, res) => {
|
||||||
|
const plan = getPlan(req.body?.planId);
|
||||||
|
if (!plan) return res.status(400).json({ error: "Unbekannter Plan." });
|
||||||
|
|
||||||
|
// ── MOCK: sofort freischalten ──────────────────────────────────────────────
|
||||||
|
if (!stripeEnabled) {
|
||||||
|
await fulfill({
|
||||||
|
accountId: req.account.id,
|
||||||
|
plan,
|
||||||
|
stripeCustomerId: "mock_cus_" + req.account.id,
|
||||||
|
stripeSubscriptionId: "mock_sub_" + req.account.id + "_" + plan.id,
|
||||||
|
status: "active",
|
||||||
|
periodEnd: new Date(Date.now() + 30 * 864e5),
|
||||||
|
});
|
||||||
|
return res.json({ mock: true, url: `${env.publicBaseUrl}/dashboard?provisioned=1` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stripe-Checkout-Session ────────────────────────────────────────────────
|
||||||
|
if (!plan.stripePriceId) return res.status(500).json({ error: "Kein Stripe-Price für diesen Plan konfiguriert." });
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: [{ price: plan.stripePriceId, quantity: 1 }],
|
||||||
|
customer_email: req.account.email,
|
||||||
|
client_reference_id: req.account.id,
|
||||||
|
metadata: { accountId: req.account.id, planId: plan.id },
|
||||||
|
success_url: `${env.publicBaseUrl}/dashboard?provisioned=1`,
|
||||||
|
cancel_url: `${env.publicBaseUrl}/plans?canceled=1`,
|
||||||
|
});
|
||||||
|
res.json({ url: session.url });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stripe-Webhook. Braucht RAW Body (Signaturprüfung) — in index.js so verdrahtet.
|
||||||
|
billingRouter.post("/webhook", async (req, res) => {
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], env.stripe.webhookSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook-Signatur ungültig:", err.message);
|
||||||
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (event.type === "checkout.session.completed") {
|
||||||
|
const s = event.data.object;
|
||||||
|
const plan = getPlan(s.metadata?.planId);
|
||||||
|
const sub = await stripe.subscriptions.retrieve(s.subscription);
|
||||||
|
await fulfill({
|
||||||
|
accountId: s.metadata?.accountId || s.client_reference_id,
|
||||||
|
plan,
|
||||||
|
stripeCustomerId: s.customer,
|
||||||
|
stripeSubscriptionId: s.subscription,
|
||||||
|
status: sub.status,
|
||||||
|
periodEnd: new Date(sub.current_period_end * 1000),
|
||||||
|
});
|
||||||
|
} else if (event.type === "customer.subscription.deleted") {
|
||||||
|
const sub = event.data.object;
|
||||||
|
await query("update subscriptions set status = 'canceled' where stripe_subscription_id = $1", [sub.id]);
|
||||||
|
await query(
|
||||||
|
`update instances set status = 'suspended'
|
||||||
|
where account_id = (select account_id from subscriptions where stripe_subscription_id = $1)`,
|
||||||
|
[sub.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook-Verarbeitung fehlgeschlagen:", err);
|
||||||
|
res.status(500).json({ error: "fulfillment failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Stripe-Client — nur initialisiert, wenn ein echter Secret-Key vorliegt.
|
||||||
|
// Im MOCK-Modus (kein Key) bleibt `stripe` null; billing.js simuliert dann.
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { env, stripeEnabled } from "./env.js";
|
||||||
|
|
||||||
|
export const stripe = stripeEnabled ? new Stripe(env.stripe.secretKey) : null;
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { auth } from "./api.js";
|
||||||
|
import Landing from "./views/Landing.jsx";
|
||||||
|
import Register from "./views/Register.jsx";
|
||||||
|
import Login from "./views/Login.jsx";
|
||||||
|
import Plans from "./views/Plans.jsx";
|
||||||
|
import Dashboard from "./views/Dashboard.jsx";
|
||||||
|
|
||||||
|
// Minimaler Pfad-Router (kein react-router nötig für 5 Seiten).
|
||||||
|
function usePath() {
|
||||||
|
const [path, setPath] = useState(window.location.pathname);
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => setPath(window.location.pathname);
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
const navigate = useCallback((to) => {
|
||||||
|
window.history.pushState({}, "", to);
|
||||||
|
setPath(to);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, []);
|
||||||
|
return [path, navigate];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [path, navigate] = usePath();
|
||||||
|
const loggedIn = auth.isLoggedIn;
|
||||||
|
|
||||||
|
// Geschützte Seiten ohne Login → Login. Login/Register mit Login → Dashboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (path === "/dashboard" && !loggedIn) navigate("/login");
|
||||||
|
if ((path === "/login" || path === "/register") && loggedIn) navigate("/dashboard");
|
||||||
|
}, [path, loggedIn, navigate]);
|
||||||
|
|
||||||
|
if (path === "/register") return <Register navigate={navigate} />;
|
||||||
|
if (path === "/login") return <Login navigate={navigate} />;
|
||||||
|
if (path === "/plans") return <Plans navigate={navigate} />;
|
||||||
|
if (path === "/dashboard") return <Dashboard navigate={navigate} />;
|
||||||
|
return <Landing navigate={navigate} />;
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
// Schmaler API-Client. Token im localStorage; /api wird im Dev geproxt.
|
||||||
|
const TOKEN_KEY = "rapport_host_token";
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
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 req(method, path, body) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
const token = auth.get();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
register: (email, password) => req("POST", "/auth/register", { email, password }),
|
||||||
|
login: (email, password) => req("POST", "/auth/login", { email, password }),
|
||||||
|
plans: () => req("GET", "/billing/plans"),
|
||||||
|
checkout: (planId) => req("POST", "/billing/checkout", { planId }),
|
||||||
|
me: () => req("GET", "/account/me"),
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(<App />);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #ebe7e1;
|
||||||
|
--card: #fdfcfa;
|
||||||
|
--ink: #1a1a18;
|
||||||
|
--muted: #8c8880;
|
||||||
|
--line: #ddd8d0;
|
||||||
|
--accent: #9a7858;
|
||||||
|
--accent-ink: #f0ede8;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "DM Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); }
|
||||||
|
.wrap { max-width: 1000px; margin: 0 auto; padding: 0 20px; }
|
||||||
|
.center { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.brand { font-family: "Krungthep", "Archivo Black", sans-serif; font-size: 32px; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.card-sm { width: 100%; max-width: 380px; }
|
||||||
|
|
||||||
|
label { display: block; font-size: 9px; letter-spacing: 0.15em; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; }
|
||||||
|
input {
|
||||||
|
width: 100%; background: #f7f4f0; border: 1.5px solid var(--line);
|
||||||
|
border-radius: 10px; padding: 11px 14px; font-family: inherit; font-size: 13px;
|
||||||
|
color: var(--ink); outline: none; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(154,120,88,0.14); }
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
width: 100%; padding: 13px; background: var(--ink); color: var(--accent-ink);
|
||||||
|
border: none; border-radius: 10px; font-family: inherit; font-size: 13px;
|
||||||
|
font-weight: 500; letter-spacing: 0.04em; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.primary:hover { background: #2e2e28; }
|
||||||
|
button.ghost {
|
||||||
|
background: none; border: none; color: var(--accent);
|
||||||
|
font-family: inherit; font-size: 12px; cursor: pointer; text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err { background: #fff5f0; border: 1px solid #f5c9b0; color: #b5621e;
|
||||||
|
padding: 9px 14px; border-radius: 8px; font-size: 11px; margin-bottom: 14px; }
|
||||||
|
.ok { background: #e8f5ee; border: 1px solid #b8dbc4; color: #2d6a4f;
|
||||||
|
padding: 9px 14px; border-radius: 8px; font-size: 11px; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
.nav { display: flex; justify-content: space-between; align-items: center; padding: 24px 0; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||||||
|
.plan { position: relative; }
|
||||||
|
.plan.rec { border-color: var(--accent); }
|
||||||
|
.plan .badge { position: absolute; top: -10px; right: 16px; background: var(--accent);
|
||||||
|
color: var(--accent-ink); font-size: 9px; padding: 3px 8px; border-radius: 6px; letter-spacing: 0.1em; }
|
||||||
|
.plan .price { font-size: 28px; margin: 8px 0; }
|
||||||
|
.plan ul { list-style: none; padding: 0; font-size: 12px; color: #555; }
|
||||||
|
.plan li { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { api, auth } from "../api.js";
|
||||||
|
|
||||||
|
export default function Dashboard({ navigate }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const justProvisioned = new URLSearchParams(window.location.search).get("provisioned") === "1";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.me().then(setData).catch((e) => setErr(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = () => { auth.clear(); navigate("/"); };
|
||||||
|
|
||||||
|
if (err) return <div className="center"><div className="card card-sm"><div className="err">{err}</div><button className="primary" onClick={logout}>Abmelden</button></div></div>;
|
||||||
|
if (!data) return <div className="center"><div className="muted">Lädt…</div></div>;
|
||||||
|
|
||||||
|
const { account, subscription, instance } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="nav">
|
||||||
|
<div className="brand" style={{ cursor: "pointer" }} onClick={() => navigate("/")}>RAPPORT</div>
|
||||||
|
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>{account.email}</span>
|
||||||
|
<button className="ghost" onClick={logout}>Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{justProvisioned && <div className="ok">Zahlung erfolgreich — Ihre Instanz wird bereitgestellt.</div>}
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 28 }}>Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: 16 }}>
|
||||||
|
<label>Abo</label>
|
||||||
|
{subscription ? (
|
||||||
|
<div style={{ fontSize: 14 }}>
|
||||||
|
Plan <b>{subscription.plan}</b> · Status <b>{subscription.status}</b>
|
||||||
|
{subscription.current_period_end && (
|
||||||
|
<span className="muted"> · läuft bis {new Date(subscription.current_period_end).toLocaleDateString("de-CH")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="muted" style={{ fontSize: 13 }}>Noch kein aktives Abo.</p>
|
||||||
|
<button className="primary" style={{ width: "auto", padding: "10px 22px" }} onClick={() => navigate("/plans")}>Abo wählen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{instance && (
|
||||||
|
<div className="card" style={{ marginTop: 16 }}>
|
||||||
|
<label>Ihre Instanz</label>
|
||||||
|
<div style={{ fontSize: 14, marginBottom: 12 }}>
|
||||||
|
<span className="muted">Status:</span> <b>{instance.status}</b>
|
||||||
|
</div>
|
||||||
|
<a className="primary" style={{ display: "inline-block", width: "auto", padding: "11px 24px", textDecoration: "none", textAlign: "center" }} href={instance.instance_url} target="_blank" rel="noreferrer">
|
||||||
|
Rapport öffnen →
|
||||||
|
</a>
|
||||||
|
<div className="muted" style={{ fontSize: 11, marginTop: 10 }}>{instance.instance_url}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { auth } from "../api.js";
|
||||||
|
|
||||||
|
export default function Landing({ navigate }) {
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="nav">
|
||||||
|
<div className="brand">RAPPORT</div>
|
||||||
|
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
|
||||||
|
<button className="ghost" onClick={() => navigate("/plans")}>Preise</button>
|
||||||
|
{auth.isLoggedIn ? (
|
||||||
|
<button className="primary" style={{ width: "auto", padding: "8px 18px" }} onClick={() => navigate("/dashboard")}>
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="primary" style={{ width: "auto", padding: "8px 18px" }} onClick={() => navigate("/login")}>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "80px 0", maxWidth: 620 }}>
|
||||||
|
<h1 style={{ fontSize: 44, lineHeight: 1.1, margin: 0 }}>
|
||||||
|
Ihre eigene Rapport-Instanz.<br />In Minuten startklar.
|
||||||
|
</h1>
|
||||||
|
<p className="muted" style={{ fontSize: 15, lineHeight: 1.6, marginTop: 20 }}>
|
||||||
|
Studio-Management für Architekturbüros — gehostet, gewartet und
|
||||||
|
gesichert. Registrieren, Abo wählen, loslegen. Ihre Daten in der
|
||||||
|
Schweiz.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 12, marginTop: 28 }}>
|
||||||
|
<button className="primary" style={{ width: "auto", padding: "13px 28px" }} onClick={() => navigate("/register")}>
|
||||||
|
Jetzt starten
|
||||||
|
</button>
|
||||||
|
<button className="primary" style={{ width: "auto", padding: "13px 28px", background: "transparent", color: "var(--ink)", border: "1px solid var(--line)" }} onClick={() => navigate("/plans")}>
|
||||||
|
Preise ansehen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { api, auth } from "../api.js";
|
||||||
|
|
||||||
|
export default function Login({ navigate }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(""); setBusy(true);
|
||||||
|
try {
|
||||||
|
const { token } = await api.login(email.trim(), password);
|
||||||
|
auth.set(token);
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="center">
|
||||||
|
<form className="card card-sm" onSubmit={submit}>
|
||||||
|
<div className="brand" style={{ textAlign: "center", marginBottom: 24 }}>RAPPORT</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11, textAlign: "center", marginBottom: 24 }}>Anmelden</div>
|
||||||
|
{err && <div className="err">{err}</div>}
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@studio.ch" autoFocus />
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••" />
|
||||||
|
<button className="primary" disabled={busy}>{busy ? "…" : "Anmelden"}</button>
|
||||||
|
<div style={{ textAlign: "center", marginTop: 16, fontSize: 11 }}>
|
||||||
|
<button type="button" className="ghost" onClick={() => navigate("/register")}>Noch kein Konto? Registrieren</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { api, auth } from "../api.js";
|
||||||
|
|
||||||
|
export default function Plans({ navigate }) {
|
||||||
|
const [plans, setPlans] = useState([]);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.plans().then((d) => setPlans(d.plans)).catch((e) => setErr(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const choose = async (planId) => {
|
||||||
|
if (!auth.isLoggedIn) { navigate("/register"); return; }
|
||||||
|
setErr(""); setBusy(planId);
|
||||||
|
try {
|
||||||
|
const { url } = await api.checkout(planId);
|
||||||
|
window.location.href = url; // Stripe-Checkout oder Mock-Erfolgs-URL
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message); setBusy("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="nav">
|
||||||
|
<div className="brand" style={{ cursor: "pointer" }} onClick={() => navigate("/")}>RAPPORT</div>
|
||||||
|
<button className="ghost" onClick={() => navigate(auth.isLoggedIn ? "/dashboard" : "/login")}>
|
||||||
|
{auth.isLoggedIn ? "Dashboard" : "Anmelden"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 32 }}>Abo wählen</h1>
|
||||||
|
<p className="muted" style={{ marginBottom: 28 }}>Jederzeit kündbar. Preise in CHF, pro Monat, exkl. MwSt.</p>
|
||||||
|
{err && <div className="err">{err}</div>}
|
||||||
|
|
||||||
|
<div className="plan-grid">
|
||||||
|
{plans.map((p) => (
|
||||||
|
<div key={p.id} className={`card plan${p.recommended ? " rec" : ""}`}>
|
||||||
|
{p.recommended && <div className="badge">EMPFOHLEN</div>}
|
||||||
|
<div style={{ fontSize: 14, letterSpacing: "0.1em", textTransform: "uppercase" }}>{p.name}</div>
|
||||||
|
<div className="price">CHF {p.priceChf}<span className="muted" style={{ fontSize: 13 }}>/{p.interval}</span></div>
|
||||||
|
<ul>{p.features.map((f, i) => <li key={i}>{f}</li>)}</ul>
|
||||||
|
<button className="primary" style={{ marginTop: 16 }} disabled={busy === p.id} onClick={() => choose(p.id)}>
|
||||||
|
{busy === p.id ? "…" : "Wählen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { api, auth } from "../api.js";
|
||||||
|
|
||||||
|
export default function Register({ navigate }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(""); setBusy(true);
|
||||||
|
try {
|
||||||
|
const { token } = await api.register(email.trim(), password);
|
||||||
|
auth.set(token);
|
||||||
|
navigate("/plans");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="center">
|
||||||
|
<form className="card card-sm" onSubmit={submit}>
|
||||||
|
<div className="brand" style={{ textAlign: "center", marginBottom: 24 }}>RAPPORT</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11, textAlign: "center", marginBottom: 24 }}>
|
||||||
|
Konto erstellen
|
||||||
|
</div>
|
||||||
|
{err && <div className="err">{err}</div>}
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@studio.ch" autoFocus />
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="min. 8 Zeichen" />
|
||||||
|
<button className="primary" disabled={busy}>{busy ? "…" : "Konto erstellen"}</button>
|
||||||
|
<div style={{ textAlign: "center", marginTop: 16, fontSize: 11 }}>
|
||||||
|
<button type="button" className="ghost" onClick={() => navigate("/login")}>Schon ein Konto? Anmelden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Frontend läuft auf :5273, Backend auf :8787 — /api wird durchgeproxt,
|
||||||
|
// damit im Dev kein CORS nötig ist und Prod-URLs identisch bleiben (/api/...).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5273,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8787",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user