diff --git a/backend/src/models/central.model.ts b/backend/src/models/central.model.ts new file mode 100644 index 0000000..afa4588 --- /dev/null +++ b/backend/src/models/central.model.ts @@ -0,0 +1,114 @@ +import { Model, ModelOptions, QueryContext } from 'objection'; +import { randomUUID } from 'crypto'; + +/** + * Central database models using Objection.js + * These models work with the central database (not tenant databases) + */ + +export class CentralTenant extends Model { + static tableName = 'tenants'; + + id: string; + name: string; + slug: string; + dbHost: string; + dbPort: number; + dbName: string; + dbUsername: string; + dbPassword: string; + status: string; + createdAt: Date; + updatedAt: Date; + + // Relations + domains?: CentralDomain[]; + + $beforeInsert(queryContext: QueryContext) { + this.id = this.id || randomUUID(); + // Auto-generate slug from name if not provided + if (!this.slug && this.name) { + this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + } + this.createdAt = new Date(); + this.updatedAt = new Date(); + } + + $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) { + this.updatedAt = new Date(); + } + + static get relationMappings() { + return { + domains: { + relation: Model.HasManyRelation, + modelClass: CentralDomain, + join: { + from: 'tenants.id', + to: 'domains.tenantId', + }, + }, + }; + } +} + +export class CentralDomain extends Model { + static tableName = 'domains'; + + id: string; + domain: string; + tenantId: string; + isPrimary: boolean; + createdAt: Date; + updatedAt: Date; + + // Relations + tenant?: CentralTenant; + + $beforeInsert(queryContext: QueryContext) { + this.id = this.id || randomUUID(); + this.createdAt = new Date(); + this.updatedAt = new Date(); + } + + $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) { + this.updatedAt = new Date(); + } + + static get relationMappings() { + return { + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: CentralTenant, + join: { + from: 'domains.tenantId', + to: 'tenants.id', + }, + }, + }; + } +} + +export class CentralUser extends Model { + static tableName = 'users'; + + id: string; + email: string; + password: string; + firstName: string | null; + lastName: string | null; + role: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + + $beforeInsert(queryContext: QueryContext) { + this.id = this.id || randomUUID(); + this.createdAt = new Date(); + this.updatedAt = new Date(); + } + + $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) { + this.updatedAt = new Date(); + } +} diff --git a/backend/src/tenant/central-admin.controller.ts b/backend/src/tenant/central-admin.controller.ts new file mode 100644 index 0000000..6ea1c9b --- /dev/null +++ b/backend/src/tenant/central-admin.controller.ts @@ -0,0 +1,257 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + UnauthorizedException, + Req, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model'; +import { getCentralKnex, initCentralModels } from './central-database.service'; +import { TenantProvisioningService } from './tenant-provisioning.service'; +import * as bcrypt from 'bcrypt'; + +/** + * Controller for managing central database entities (tenants, domains, users) + * Only accessible when logged in as central admin + */ +@Controller('central') +@UseGuards(JwtAuthGuard) +export class CentralAdminController { + constructor( + private readonly provisioningService: TenantProvisioningService, + ) { + // Initialize central models on controller creation + initCentralModels(); + } + + private checkCentralAdmin(req: any) { + const subdomain = req.raw?.subdomain; + const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(','); + + if (!subdomain || !centralSubdomains.includes(subdomain)) { + throw new UnauthorizedException('This endpoint is only accessible to central administrators'); + } + } + + // ==================== TENANTS ==================== + + @Get('tenants') + async getTenants(@Req() req: any) { + this.checkCentralAdmin(req); + return CentralTenant.query().withGraphFetched('domains'); + } + + @Get('tenants/:id') + async getTenant(@Req() req: any, @Param('id') id: string) { + this.checkCentralAdmin(req); + return CentralTenant.query() + .findById(id) + .withGraphFetched('domains'); + } + + @Post('tenants') + async createTenant( + @Req() req: any, + @Body() data: { + name: string; + slug?: string; + primaryDomain: string; + dbHost?: string; + dbPort?: number; + }, + ) { + this.checkCentralAdmin(req); + + // Use the provisioning service to create tenant with database and migrations + const result = await this.provisioningService.provisionTenant({ + name: data.name, + slug: data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''), + primaryDomain: data.primaryDomain, + dbHost: data.dbHost, + dbPort: data.dbPort, + }); + + // Return the created tenant + return CentralTenant.query() + .findById(result.tenantId) + .withGraphFetched('domains'); + } + + @Put('tenants/:id') + async updateTenant( + @Req() req: any, + @Param('id') id: string, + @Body() data: { + name?: string; + slug?: string; + dbHost?: string; + dbPort?: number; + dbName?: string; + dbUsername?: string; + status?: string; + }, + ) { + this.checkCentralAdmin(req); + return CentralTenant.query() + .patchAndFetchById(id, data); + } + + @Delete('tenants/:id') + async deleteTenant(@Req() req: any, @Param('id') id: string) { + this.checkCentralAdmin(req); + await CentralTenant.query().deleteById(id); + return { success: true }; + } + + // ==================== DOMAINS ==================== + + @Get('domains') + async getDomains(@Req() req: any) { + this.checkCentralAdmin(req); + return CentralDomain.query().withGraphFetched('tenant'); + } + + @Get('domains/:id') + async getDomain(@Req() req: any, @Param('id') id: string) { + this.checkCentralAdmin(req); + return CentralDomain.query() + .findById(id) + .withGraphFetched('tenant'); + } + + @Post('domains') + async createDomain( + @Req() req: any, + @Body() data: { + domain: string; + tenantId: string; + isPrimary?: boolean; + }, + ) { + this.checkCentralAdmin(req); + return CentralDomain.query().insert({ + domain: data.domain, + tenantId: data.tenantId, + isPrimary: data.isPrimary || false, + }); + } + + @Put('domains/:id') + async updateDomain( + @Req() req: any, + @Param('id') id: string, + @Body() data: { + domain?: string; + tenantId?: string; + isPrimary?: boolean; + }, + ) { + this.checkCentralAdmin(req); + return CentralDomain.query() + .patchAndFetchById(id, data); + } + + @Delete('domains/:id') + async deleteDomain(@Req() req: any, @Param('id') id: string) { + this.checkCentralAdmin(req); + await CentralDomain.query().deleteById(id); + return { success: true }; + } + + // ==================== USERS (Central Admin Users) ==================== + + @Get('users') + async getUsers(@Req() req: any) { + this.checkCentralAdmin(req); + const users = await CentralUser.query(); + // Remove password from response + return users.map(({ password, ...user }) => user); + } + + @Get('users/:id') + async getUser(@Req() req: any, @Param('id') id: string) { + this.checkCentralAdmin(req); + const user = await CentralUser.query().findById(id); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + @Post('users') + async createUser( + @Req() req: any, + @Body() data: { + email: string; + password: string; + firstName?: string; + lastName?: string; + role?: string; + isActive?: boolean; + }, + ) { + this.checkCentralAdmin(req); + + const hashedPassword = await bcrypt.hash(data.password, 10); + + const user = await CentralUser.query().insert({ + email: data.email, + password: hashedPassword, + firstName: data.firstName || null, + lastName: data.lastName || null, + role: data.role || 'admin', + isActive: data.isActive !== undefined ? data.isActive : true, + }); + + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + @Put('users/:id') + async updateUser( + @Req() req: any, + @Param('id') id: string, + @Body() data: { + email?: string; + password?: string; + firstName?: string; + lastName?: string; + role?: string; + isActive?: boolean; + }, + ) { + this.checkCentralAdmin(req); + + const updateData: any = { ...data }; + + // Hash password if provided + if (data.password) { + updateData.password = await bcrypt.hash(data.password, 10); + } else { + // Remove password from update if not provided + delete updateData.password; + } + + const user = await CentralUser.query() + .patchAndFetchById(id, updateData); + + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + @Delete('users/:id') + async deleteUser(@Req() req: any, @Param('id') id: string) { + this.checkCentralAdmin(req); + await CentralUser.query().deleteById(id); + return { success: true }; + } +} diff --git a/backend/src/tenant/central-database.service.ts b/backend/src/tenant/central-database.service.ts new file mode 100644 index 0000000..2c39109 --- /dev/null +++ b/backend/src/tenant/central-database.service.ts @@ -0,0 +1,43 @@ +import Knex from 'knex'; +import { Model } from 'objection'; +import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model'; + +let centralKnex: Knex.Knex | null = null; + +/** + * Get or create a Knex instance for the central database + * This is used for Objection models that work with central entities + */ +export function getCentralKnex(): Knex.Knex { + if (!centralKnex) { + const centralDbUrl = process.env.CENTRAL_DATABASE_URL; + + if (!centralDbUrl) { + throw new Error('CENTRAL_DATABASE_URL environment variable is not set'); + } + + centralKnex = Knex({ + client: 'mysql2', + connection: centralDbUrl, + pool: { + min: 2, + max: 10, + }, + }); + + // Bind Objection models to this knex instance + Model.knex(centralKnex); + } + + return centralKnex; +} + +/** + * Initialize central models with the knex instance + */ +export function initCentralModels() { + const knex = getCentralKnex(); + CentralTenant.knex(knex); + CentralDomain.knex(knex); + CentralUser.knex(knex); +} diff --git a/backend/src/tenant/tenant.module.ts b/backend/src/tenant/tenant.module.ts index a2ad485..209ed06 100644 --- a/backend/src/tenant/tenant.module.ts +++ b/backend/src/tenant/tenant.module.ts @@ -3,11 +3,12 @@ import { TenantMiddleware } from './tenant.middleware'; import { TenantDatabaseService } from './tenant-database.service'; import { TenantProvisioningService } from './tenant-provisioning.service'; import { TenantProvisioningController } from './tenant-provisioning.controller'; +import { CentralAdminController } from './central-admin.controller'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ imports: [PrismaModule], - controllers: [TenantProvisioningController], + controllers: [TenantProvisioningController, CentralAdminController], providers: [ TenantDatabaseService, TenantProvisioningService, diff --git a/frontend/components/AppSidebar.vue b/frontend/components/AppSidebar.vue index 21584f1..9d9f6f4 100644 --- a/frontend/components/AppSidebar.vue +++ b/frontend/components/AppSidebar.vue @@ -17,7 +17,7 @@ import { SidebarRail, } from '@/components/ui/sidebar' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut } from 'lucide-vue-next' +import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building } from 'lucide-vue-next' const { logout } = useAuth() const { api } = useApi() @@ -26,12 +26,30 @@ const handleLogout = async () => { await logout() } +// Check if user is central admin (by checking if we're on a central subdomain) +const isCentralAdmin = computed(() => { + if (process.client) { + const hostname = window.location.hostname + const parts = hostname.split('.') + const subdomain = parts.length >= 2 ? parts[0] : null + const centralSubdomains = ['central', 'admin'] + return subdomain && centralSubdomains.includes(subdomain) + } + return false +}) + // Fetch objects and group by app const apps = ref([]) const topLevelObjects = ref([]) const loading = ref(true) onMounted(async () => { + // Don't fetch tenant objects if we're on a central subdomain + if (isCentralAdmin.value) { + loading.value = false + return + } + try { const response = await api.get('/setup/objects') const allObjects = response.data || response || [] @@ -89,6 +107,30 @@ const staticMenuItems = [ ], }, ] + +const centralAdminMenuItems = [ + { + title: 'Central Admin', + icon: Settings, + items: [ + { + title: 'Tenants', + url: '/central/tenants', + icon: Building, + }, + { + title: 'Domains', + url: '/central/domains', + icon: Globe, + }, + { + title: 'Admin Users', + url: '/central/users', + icon: Users, + }, + ], + }, +]