🏢 Multi-Tenant SaaS: Org-Registrierung + Super-Admin Panel
This commit is contained in:
@@ -6,6 +6,8 @@ import { ordersRouter } from "./routes/orders.ts";
|
|||||||
import { availabilityRouter } from "./routes/availability.ts";
|
import { availabilityRouter } from "./routes/availability.ts";
|
||||||
import { timesheetsRouter } from "./routes/timesheets.ts";
|
import { timesheetsRouter } from "./routes/timesheets.ts";
|
||||||
import { modulesRouter } from "./routes/modules.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 { errorHandler } from "./middleware/error.ts";
|
||||||
import { requestLogger } from "./middleware/logger.ts";
|
import { requestLogger } from "./middleware/logger.ts";
|
||||||
import { initDB } from "./db/postgres.ts";
|
import { initDB } from "./db/postgres.ts";
|
||||||
@@ -41,6 +43,10 @@ app.use(timesheetsRouter.routes());
|
|||||||
app.use(timesheetsRouter.allowedMethods());
|
app.use(timesheetsRouter.allowedMethods());
|
||||||
app.use(modulesRouter.routes());
|
app.use(modulesRouter.routes());
|
||||||
app.use(modulesRouter.allowedMethods());
|
app.use(modulesRouter.allowedMethods());
|
||||||
|
app.use(organizationsRouter.routes());
|
||||||
|
app.use(organizationsRouter.allowedMethods());
|
||||||
|
app.use(adminRouter.routes());
|
||||||
|
app.use(adminRouter.allowedMethods());
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.use((ctx) => {
|
app.use((ctx) => {
|
||||||
|
|||||||
267
src/routes/admin.ts
Normal file
267
src/routes/admin.ts
Normal 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
175
src/routes/organizations.ts
Normal 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" };
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user