🏢 Multi-Tenant SaaS: Org-Registrierung + Super-Admin Panel

This commit is contained in:
2026-03-12 16:08:02 +00:00
parent ee19e45171
commit 256b8e20a5
3 changed files with 448 additions and 0 deletions

View File

@@ -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) => {

267
src/routes/admin.ts Normal file
View File

@@ -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<void> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown> }>(
"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
};
});

175
src/routes/organizations.ts Normal file
View File

@@ -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<string, unknown> }>(
"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" };
});