From 7e38fc68bd6fc8c93f5ca5ba9136822494c277d8 Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 30 May 2026 23:57:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(account):=20Profil-Felder,=20Instanz-Liste?= =?UTF-8?q?,=20Profil-Update=20+=20Passwort=20=C3=A4ndern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0002_account_profile.sql: company/contact_name/adresse/phone + instances.label - GET /account/me: alle Profilfelder + instances[] (Multi-Instanz vorbereitet), instance einzeln bleibt rückwärtskompatibel - PATCH /account/me: Profil aktualisieren (Whitelist) - POST /account/password: Passwort ändern (prüft aktuelles PW → 403 sonst) E2E verifiziert: Profil speichern, PW-Wechsel (falsch=403/richtig=200), instances[]=1 nach Checkout. Co-Authored-By: Claude Opus 4.8 --- server/migrations/0002_account_profile.sql | 15 +++++ server/routes/account.js | 68 +++++++++++++++++++--- 2 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 server/migrations/0002_account_profile.sql diff --git a/server/migrations/0002_account_profile.sql b/server/migrations/0002_account_profile.sql new file mode 100644 index 0000000..ed7cad9 --- /dev/null +++ b/server/migrations/0002_account_profile.sql @@ -0,0 +1,15 @@ +-- RAPPORT-HOST — Konto-Profilfelder (für "Mein Konto" + QR-Rechnung später). +-- Idempotent via IF NOT EXISTS, damit der Migrations-Runner mehrfach laufen kann. + +alter table accounts add column if not exists company text; +alter table accounts add column if not exists contact_name text; +alter table accounts add column if not exists street text; +alter table accounts add column if not exists zip text; +alter table accounts add column if not exists city text; +alter table accounts add column if not exists country text default 'CH'; +alter table accounts add column if not exists phone text; +alter table accounts add column if not exists updated_at timestamptz not null default now(); + +-- Optionaler Anzeigename pro Instanz (Kunde benennt seine Instanzen selbst), +-- damit eine Instanz-Liste im Konto lesbar ist (statt nur slug). +alter table instances add column if not exists label text; diff --git a/server/routes/account.js b/server/routes/account.js index 71cc76e..2706b97 100644 --- a/server/routes/account.js +++ b/server/routes/account.js @@ -1,26 +1,78 @@ -// Kunden-Dashboard-Daten: Konto + aktuelles Abo + bereitgestellte Instanz. +// Kunden-Konto: Profil, Abo, Instanzen (Liste), Profil-Update, Passwort ändern. import { Router } from "express"; -import { one } from "../db.js"; -import { requireAuth } from "../auth.js"; +import { one, query } from "../db.js"; +import { requireAuth, hashPassword, verifyPassword } from "../auth.js"; import { publicPlans } from "../plans.js"; export const accountRouter = Router(); +// Profil-Spalten, die der Kunde selbst bearbeiten darf (Whitelist). +const PROFILE_FIELDS = ["company", "contact_name", "street", "zip", "city", "country", "phone"]; + +// ── 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 + from accounts where id = $1`, + [req.account.id] + ); + if (!account) return res.status(404).json({ error: "Konto nicht gefunden." }); + 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`, + const { rows: instances } = await query( + `select id, studio_slug, label, instance_url, status, created_at + from instances where account_id = $1 order by created_at asc`, [req.account.id] ); + res.json({ - account: req.account, + account, subscription: subscription || null, - instance: instance || null, + instances, + // Rückwärtskompatibel: erste Instanz auch einzeln (altes Frontend). + instance: instances[0] || null, plans: publicPlans(), }); }); + +// ── Profil aktualisieren ───────────────────────────────────────────────────── +accountRouter.patch("/me", requireAuth, async (req, res) => { + const updates = []; + const values = []; + for (const f of PROFILE_FIELDS) { + if (req.body[f] !== undefined) { + values.push(req.body[f] === "" ? null : req.body[f]); + updates.push(`${f} = $${values.length}`); + } + } + if (!updates.length) return res.status(400).json({ error: "Keine Felder zum Aktualisieren." }); + values.push(req.account.id); + const account = await one( + `update accounts set ${updates.join(", ")}, updated_at = now() + where id = $${values.length} + returning id, email, company, contact_name, street, zip, city, country, phone`, + values + ); + res.json({ account }); +}); + +// ── Passwort ändern ────────────────────────────────────────────────────────── +accountRouter.post("/password", requireAuth, async (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!newPassword || newPassword.length < 8) { + return res.status(400).json({ error: "Neues Passwort min. 8 Zeichen." }); + } + const row = await one("select password_hash from accounts where id = $1", [req.account.id]); + if (!row || !(await verifyPassword(currentPassword || "", row.password_hash))) { + return res.status(403).json({ error: "Aktuelles Passwort falsch." }); + } + await query("update accounts set password_hash = $1, updated_at = now() where id = $2", [ + await hashPassword(newPassword), + req.account.id, + ]); + res.json({ ok: true }); +});