From 256b8e20a5fe8fae7b3868ff302fa7e3523b2f63 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Thu, 12 Mar 2026 16:08:02 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=A2=20Multi-Tenant=20SaaS:=20Org-Regis?= =?UTF-8?q?trierung=20+=20Super-Admin=20Panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.ts | 6 + src/routes/admin.ts | 267 ++++++++++++++++++++++++++++++++++++ src/routes/organizations.ts | 175 +++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/routes/admin.ts create mode 100644 src/routes/organizations.ts diff --git a/src/main.ts b/src/main.ts index 5d0d1d3..3818ddd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { ordersRouter } from "./routes/orders.ts"; import { availabilityRouter } from "./routes/availability.ts"; import { timesheetsRouter } from "./routes/timesheets.ts"; import { modulesRouter } from "./routes/modules.ts"; +import { organizationsRouter } from "./routes/organizations.ts"; +import { adminRouter } from "./routes/admin.ts"; import { errorHandler } from "./middleware/error.ts"; import { requestLogger } from "./middleware/logger.ts"; import { initDB } from "./db/postgres.ts"; @@ -41,6 +43,10 @@ app.use(timesheetsRouter.routes()); app.use(timesheetsRouter.allowedMethods()); app.use(modulesRouter.routes()); app.use(modulesRouter.allowedMethods()); +app.use(organizationsRouter.routes()); +app.use(organizationsRouter.allowedMethods()); +app.use(adminRouter.routes()); +app.use(adminRouter.allowedMethods()); // Health check app.use((ctx) => { diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..fda4abc --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,267 @@ +import { Router } from "@oak/oak"; +import { query, queryOne, execute } from "../db/postgres.ts"; +import { AppError } from "../middleware/error.ts"; +import type { Context, Next } from "@oak/oak"; + +export const adminRouter = new Router({ prefix: "/api/admin" }); + +// Super Admin emails (hardcoded for security) +const SUPER_ADMINS = [ + "admin@kronos-soulution.de", + "marcel@kronos-soulution.de" +]; + +// Super admin middleware +async function requireSuperAdmin(ctx: Context, next: Next): Promise { + const authHeader = ctx.request.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + throw new AppError("Nicht autorisiert", 401); + } + + const token = authHeader.slice(7); + const { verifyToken } = await import("../utils/auth.ts"); + const payload = await verifyToken(token); + + if (!payload || !SUPER_ADMINS.includes(payload.email)) { + throw new AppError("Super-Admin Berechtigung erforderlich", 403); + } + + ctx.state.admin = { email: payload.email }; + await next(); +} + +// Dashboard stats +adminRouter.get("/dashboard", requireSuperAdmin, async (ctx) => { + const stats = await queryOne<{ + org_count: number; + user_count: number; + order_count: number; + timesheet_count: number; + }>( + `SELECT + (SELECT COUNT(*) FROM organizations) as org_count, + (SELECT COUNT(*) FROM users WHERE active = true) as user_count, + (SELECT COUNT(*) FROM orders) as order_count, + (SELECT COUNT(*) FROM timesheets) as timesheet_count` + ); + + // Recent orgs + const recentOrgs = await query<{ id: string; name: string; slug: string; created_at: Date }>( + `SELECT id, name, slug, created_at + FROM organizations + ORDER BY created_at DESC + LIMIT 10` + ); + + ctx.response.body = { stats, recentOrgs }; +}); + +// List all organizations +adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => { + const orgs = await query<{ + id: string; + name: string; + slug: string; + settings: Record; + created_at: Date; + user_count: number; + order_count: number; + }>( + `SELECT o.*, + (SELECT COUNT(*) FROM users WHERE org_id = o.id AND active = true) as user_count, + (SELECT COUNT(*) FROM orders WHERE org_id = o.id) as order_count + FROM organizations o + ORDER BY o.created_at DESC` + ); + + ctx.response.body = { organizations: orgs }; +}); + +// Get single organization details +adminRouter.get("/organizations/:id", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + + const org = await queryOne<{ + id: string; + name: string; + slug: string; + settings: Record; + created_at: Date; + }>( + "SELECT * FROM organizations WHERE id = $1", + [orgId] + ); + + if (!org) { + throw new AppError("Organisation nicht gefunden", 404); + } + + // Get users + const users = await query<{ + id: string; + email: string; + role: string; + first_name: string; + last_name: string; + active: boolean; + last_login: Date; + }>( + `SELECT id, email, role, first_name, last_name, active, last_login + FROM users WHERE org_id = $1 + ORDER BY role, last_name`, + [orgId] + ); + + // Get stats + const stats = await queryOne<{ + order_count: number; + timesheet_count: number; + enabled_modules: number; + }>( + `SELECT + (SELECT COUNT(*) FROM orders WHERE org_id = $1) as order_count, + (SELECT COUNT(*) FROM timesheets t JOIN users u ON t.user_id = u.id WHERE u.org_id = $1) as timesheet_count, + (SELECT COUNT(*) FROM organization_modules WHERE org_id = $1 AND enabled = true) as enabled_modules`, + [orgId] + ); + + ctx.response.body = { organization: org, users, stats }; +}); + +// Update organization (plan, settings, etc.) +adminRouter.put("/organizations/:id", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { name, settings, plan } = body; + + // Get current org + const org = await queryOne<{ settings: Record }>( + "SELECT settings FROM organizations WHERE id = $1", + [orgId] + ); + + if (!org) { + throw new AppError("Organisation nicht gefunden", 404); + } + + // Merge settings + const newSettings = { + ...org.settings, + ...settings, + ...(plan ? { plan } : {}) + }; + + await execute( + `UPDATE organizations SET name = COALESCE($1, name), settings = $2 WHERE id = $3`, + [name || null, JSON.stringify(newSettings), orgId] + ); + + ctx.response.body = { message: "Organisation aktualisiert" }; +}); + +// Delete organization (careful!) +adminRouter.delete("/organizations/:id", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { confirm } = body; + + if (confirm !== "DELETE") { + throw new AppError("Bestätigung erforderlich: confirm = 'DELETE'", 400); + } + + // Check it's not demo org + const org = await queryOne<{ slug: string }>( + "SELECT slug FROM organizations WHERE id = $1", + [orgId] + ); + + if (org?.slug === "demo") { + throw new AppError("Demo-Organisation kann nicht gelöscht werden", 400); + } + + // Delete (cascades to users, orders, etc.) + await execute("DELETE FROM organizations WHERE id = $1", [orgId]); + + ctx.response.body = { message: "Organisation gelöscht" }; +}); + +// Impersonate: Get login token for any user (for support) +adminRouter.post("/impersonate/:userId", requireSuperAdmin, async (ctx) => { + const userId = ctx.params.userId; + + const user = await queryOne<{ + id: string; + org_id: string; + email: string; + role: string; + }>( + "SELECT id, org_id, email, role FROM users WHERE id = $1 AND active = true", + [userId] + ); + + if (!user) { + throw new AppError("User nicht gefunden", 404); + } + + const { generateAccessToken } = await import("../utils/auth.ts"); + const token = await generateAccessToken(user.id, user.org_id, user.role as any, user.email); + + ctx.response.body = { + message: "Impersonation Token erstellt", + token, + user: { + id: user.id, + email: user.email, + role: user.role + } + }; +}); + +// System info +adminRouter.get("/system", requireSuperAdmin, async (ctx) => { + const dbVersion = await queryOne<{ version: string }>("SELECT version()"); + + ctx.response.body = { + service: "secu-backend", + version: "1.0.0", + environment: Deno.env.get("DENO_ENV") || "production", + database: dbVersion?.version?.split(" ")[1] || "unknown", + uptime: Deno.osUptime?.() || "unknown", + memory: Deno.memoryUsage?.() || {}, + superAdmins: SUPER_ADMINS + }; +}); + +// Broadcast message to all orgs (for announcements) +adminRouter.post("/broadcast", requireSuperAdmin, async (ctx) => { + const body = await ctx.request.body.json(); + const { message, type } = body; + + if (!message) { + throw new AppError("Nachricht erforderlich", 400); + } + + // Store broadcast in all org settings + const orgs = await query<{ id: string }>("SELECT id FROM organizations"); + + for (const org of orgs) { + await execute( + `UPDATE organizations + SET settings = settings || $1::jsonb + WHERE id = $2`, + [JSON.stringify({ + broadcast: { + message, + type: type || "info", + created_at: new Date().toISOString() + } + }), org.id] + ); + } + + ctx.response.body = { + message: "Broadcast gesendet", + affected: orgs.length + }; +}); diff --git a/src/routes/organizations.ts b/src/routes/organizations.ts new file mode 100644 index 0000000..65b8c6c --- /dev/null +++ b/src/routes/organizations.ts @@ -0,0 +1,175 @@ +import { Router } from "@oak/oak"; +import { query, queryOne, execute } from "../db/postgres.ts"; +import { AppError } from "../middleware/error.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const organizationsRouter = new Router({ prefix: "/api/organizations" }); + +// Public: Check if org slug is available +organizationsRouter.get("/check/:slug", async (ctx) => { + const slug = ctx.params.slug.toLowerCase().replace(/[^a-z0-9-]/g, ""); + + const existing = await queryOne<{ id: string }>( + "SELECT id FROM organizations WHERE slug = $1", + [slug] + ); + + ctx.response.body = { + slug, + available: !existing + }; +}); + +// Public: Register new organization +organizationsRouter.post("/register", async (ctx) => { + const body = await ctx.request.body.json(); + const { name, slug, admin_email, admin_password, admin_first_name, admin_last_name, admin_phone } = body; + + if (!name || !slug || !admin_email || !admin_password || !admin_first_name || !admin_last_name) { + throw new AppError("Alle Pflichtfelder ausfüllen", 400); + } + + // Validate slug + const cleanSlug = slug.toLowerCase().replace(/[^a-z0-9-]/g, ""); + if (cleanSlug.length < 3) { + throw new AppError("Slug muss mindestens 3 Zeichen haben", 400); + } + + // Check if slug exists + const existing = await queryOne<{ id: string }>( + "SELECT id FROM organizations WHERE slug = $1", + [cleanSlug] + ); + + if (existing) { + throw new AppError("Diese Organisation existiert bereits", 409); + } + + // Check if email is already used + const existingEmail = await queryOne<{ id: string }>( + "SELECT id FROM users WHERE email = $1", + [admin_email] + ); + + if (existingEmail) { + throw new AppError("Diese E-Mail ist bereits registriert", 409); + } + + // Import hash function + const { hashPassword } = await import("../utils/auth.ts"); + + // Create organization + const org = await queryOne<{ id: string }>( + `INSERT INTO organizations (name, slug, settings) + VALUES ($1, $2, $3) + RETURNING id`, + [name, cleanSlug, JSON.stringify({ + created_at: new Date().toISOString(), + plan: "free", + features: ["core", "orders", "availability", "timesheets"] + })] + ); + + if (!org) { + throw new AppError("Fehler beim Erstellen der Organisation", 500); + } + + // Create admin user (chef) + const passwordHash = await hashPassword(admin_password); + + const user = await queryOne<{ id: string }>( + `INSERT INTO users (org_id, email, password_hash, role, first_name, last_name, phone, active) + VALUES ($1, $2, $3, 'chef', $4, $5, $6, true) + RETURNING id`, + [org.id, admin_email, passwordHash, admin_first_name, admin_last_name, admin_phone || null] + ); + + // Enable default modules for org + await execute( + `INSERT INTO organization_modules (org_id, module_id, enabled) + SELECT $1, id, true FROM modules WHERE name IN ('core', 'orders', 'availability', 'timesheets')`, + [org.id] + ); + + ctx.response.status = 201; + ctx.response.body = { + message: "Organisation erfolgreich erstellt", + organization: { + id: org.id, + name, + slug: cleanSlug + }, + admin: { + id: user?.id, + email: admin_email + } + }; +}); + +// Get current organization info +organizationsRouter.get("/current", authMiddleware, async (ctx) => { + const { org_id: orgId } = ctx.state.auth.user; + + const org = await queryOne<{ id: string; name: string; slug: string; settings: Record }>( + "SELECT id, name, slug, settings FROM organizations WHERE id = $1", + [orgId] + ); + + if (!org) { + throw new AppError("Organisation nicht gefunden", 404); + } + + // Get stats + const stats = await queryOne<{ user_count: number; order_count: number }>( + `SELECT + (SELECT COUNT(*) FROM users WHERE org_id = $1 AND active = true) as user_count, + (SELECT COUNT(*) FROM orders WHERE org_id = $1) as order_count`, + [orgId] + ); + + ctx.response.body = { + organization: org, + stats + }; +}); + +// Update organization settings (chef only) +organizationsRouter.put("/current", authMiddleware, async (ctx) => { + const { org_id: orgId, role } = ctx.state.auth.user; + + if (role !== "chef") { + throw new AppError("Nur der Chef kann Einstellungen ändern", 403); + } + + const body = await ctx.request.body.json(); + const { name, settings } = body; + + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (name) { + updates.push(`name = $${paramIndex}`); + values.push(name); + paramIndex++; + } + + if (settings) { + updates.push(`settings = settings || $${paramIndex}::jsonb`); + values.push(JSON.stringify(settings)); + paramIndex++; + } + + if (updates.length === 0) { + throw new AppError("Keine Änderungen", 400); + } + + values.push(orgId); + + await execute( + `UPDATE organizations SET ${updates.join(", ")} WHERE id = $${paramIndex}`, + values + ); + + ctx.response.body = { message: "Einstellungen gespeichert" }; +});