🏢 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 { 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
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