WIP - central operations
This commit is contained in:
114
backend/src/models/central.model.ts
Normal file
114
backend/src/models/central.model.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
257
backend/src/tenant/central-admin.controller.ts
Normal file
257
backend/src/tenant/central-admin.controller.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/tenant/central-database.service.ts
Normal file
43
backend/src/tenant/central-database.service.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import { TenantMiddleware } from './tenant.middleware';
|
|||||||
import { TenantDatabaseService } from './tenant-database.service';
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||||
|
import { CentralAdminController } from './central-admin.controller';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [TenantProvisioningController],
|
controllers: [TenantProvisioningController, CentralAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
TenantDatabaseService,
|
TenantDatabaseService,
|
||||||
TenantProvisioningService,
|
TenantProvisioningService,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
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 { logout } = useAuth()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
@@ -26,12 +26,30 @@ const handleLogout = async () => {
|
|||||||
await logout()
|
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
|
// Fetch objects and group by app
|
||||||
const apps = ref<any[]>([])
|
const apps = ref<any[]>([])
|
||||||
const topLevelObjects = ref<any[]>([])
|
const topLevelObjects = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Don't fetch tenant objects if we're on a central subdomain
|
||||||
|
if (isCentralAdmin.value) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/setup/objects')
|
const response = await api.get('/setup/objects')
|
||||||
const allObjects = response.data || response || []
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -160,6 +202,53 @@ const staticMenuItems = [
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<!-- Central Admin Menu Items (only visible to central admins) -->
|
||||||
|
<SidebarGroup v-if="isCentralAdmin">
|
||||||
|
<SidebarGroupLabel>Central Administration</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<template v-for="item in centralAdminMenuItems" :key="item.title">
|
||||||
|
<!-- Simple menu item -->
|
||||||
|
<SidebarMenuItem v-if="!item.items">
|
||||||
|
<SidebarMenuButton as-child>
|
||||||
|
<NuxtLink :to="item.url">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
|
||||||
|
<!-- Collapsible menu item with submenu -->
|
||||||
|
<Collapsible v-else as-child :default-open="true" class="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger as-child>
|
||||||
|
<SidebarMenuButton :tooltip="item.title">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<ChevronRight
|
||||||
|
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||||
|
/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||||
|
<SidebarMenuSubButton as-child>
|
||||||
|
<NuxtLink :to="subItem.url">
|
||||||
|
<component v-if="subItem.icon" :is="subItem.icon" />
|
||||||
|
<span>{{ subItem.title }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
</template>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
<!-- Top-level Objects (no app) -->
|
<!-- Top-level Objects (no app) -->
|
||||||
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
|
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
|
||||||
<SidebarGroupLabel>Objects</SidebarGroupLabel>
|
<SidebarGroupLabel>Objects</SidebarGroupLabel>
|
||||||
|
|||||||
386
frontend/composables/useCentralEntities.ts
Normal file
386
frontend/composables/useCentralEntities.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Static field configurations for central database entities
|
||||||
|
* These entities don't have dynamic field definitions like tenant objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FieldType, ViewMode } from '@/types/field-types'
|
||||||
|
import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig } from '@/types/field-types'
|
||||||
|
|
||||||
|
// ==================== TENANTS ====================
|
||||||
|
|
||||||
|
export const tenantFields: FieldConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
apiName: 'name',
|
||||||
|
label: 'Tenant Name',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slug',
|
||||||
|
apiName: 'slug',
|
||||||
|
label: 'Slug',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: false,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
sortable: true,
|
||||||
|
helpText: 'Unique identifier for the tenant (auto-generated from name if not provided)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'primaryDomain',
|
||||||
|
apiName: 'primaryDomain',
|
||||||
|
label: 'Primary Domain',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: false,
|
||||||
|
showOnEdit: true,
|
||||||
|
helpText: 'Primary subdomain for this tenant (e.g., "acme" for acme.yourdomain.com)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
apiName: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: FieldType.SELECT,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Suspended', value: 'suspended' },
|
||||||
|
{ label: 'Deleted', value: 'deleted' },
|
||||||
|
],
|
||||||
|
defaultValue: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dbHost',
|
||||||
|
apiName: 'dbHost',
|
||||||
|
label: 'Database Host',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: false,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
helpText: 'Leave blank to use default database host',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dbPort',
|
||||||
|
apiName: 'dbPort',
|
||||||
|
label: 'Database Port',
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
isRequired: false,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
defaultValue: 3306,
|
||||||
|
helpText: 'Leave blank to use default port (3306)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dbName',
|
||||||
|
apiName: 'dbName',
|
||||||
|
label: 'Database Name',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: false,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: false,
|
||||||
|
helpText: 'Auto-generated based on tenant slug',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dbUsername',
|
||||||
|
apiName: 'dbUsername',
|
||||||
|
label: 'Database Username',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: false,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: false,
|
||||||
|
helpText: 'Auto-generated based on tenant slug',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
apiName: 'createdAt',
|
||||||
|
label: 'Created At',
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: false,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updatedAt',
|
||||||
|
apiName: 'updatedAt',
|
||||||
|
label: 'Updated At',
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const tenantListConfig: ListViewConfig = {
|
||||||
|
objectApiName: 'Tenant',
|
||||||
|
mode: ViewMode.LIST,
|
||||||
|
fields: tenantFields,
|
||||||
|
pageSize: 25,
|
||||||
|
searchable: true,
|
||||||
|
filterable: true,
|
||||||
|
exportable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tenantDetailConfig: DetailViewConfig = {
|
||||||
|
objectApiName: 'Tenant',
|
||||||
|
mode: ViewMode.DETAIL,
|
||||||
|
fields: tenantFields,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Basic Information',
|
||||||
|
fields: ['name', 'slug', 'status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Database Configuration',
|
||||||
|
fields: ['dbHost', 'dbPort', 'dbName', 'dbUsername'],
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System Information',
|
||||||
|
fields: ['createdAt', 'updatedAt'],
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tenantEditConfig: EditViewConfig = {
|
||||||
|
objectApiName: 'Tenant',
|
||||||
|
mode: ViewMode.EDIT,
|
||||||
|
fields: tenantFields,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Basic Information',
|
||||||
|
fields: ['name', 'slug', 'primaryDomain', 'status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Advanced Options',
|
||||||
|
description: 'Optional database configuration (leave blank for defaults)',
|
||||||
|
fields: ['dbHost', 'dbPort'],
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DOMAINS ====================
|
||||||
|
|
||||||
|
export const domainFields: FieldConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'domain',
|
||||||
|
apiName: 'domain',
|
||||||
|
label: 'Domain',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
sortable: true,
|
||||||
|
helpText: 'Subdomain for this tenant (e.g., "acme" for acme.yourapp.com)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tenantId',
|
||||||
|
apiName: 'tenantId',
|
||||||
|
label: 'Tenant',
|
||||||
|
type: FieldType.BELONGS_TO,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
relationObject: 'Tenant',
|
||||||
|
relationDisplayField: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'isPrimary',
|
||||||
|
apiName: 'isPrimary',
|
||||||
|
label: 'Primary Domain',
|
||||||
|
type: FieldType.BOOLEAN,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
defaultValue: false,
|
||||||
|
helpText: 'Mark as the primary domain for this tenant',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
apiName: 'createdAt',
|
||||||
|
label: 'Created At',
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: false,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const domainListConfig: ListViewConfig = {
|
||||||
|
objectApiName: 'Domain',
|
||||||
|
mode: ViewMode.LIST,
|
||||||
|
fields: domainFields,
|
||||||
|
pageSize: 25,
|
||||||
|
searchable: true,
|
||||||
|
filterable: true,
|
||||||
|
exportable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const domainDetailConfig: DetailViewConfig = {
|
||||||
|
objectApiName: 'Domain',
|
||||||
|
mode: ViewMode.DETAIL,
|
||||||
|
fields: domainFields,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Domain Information',
|
||||||
|
fields: ['domain', 'tenantId', 'isPrimary', 'createdAt'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const domainEditConfig: EditViewConfig = {
|
||||||
|
objectApiName: 'Domain',
|
||||||
|
mode: ViewMode.EDIT,
|
||||||
|
fields: domainFields,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Domain Configuration',
|
||||||
|
fields: ['domain', 'tenantId', 'isPrimary'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USERS (Central Admin Users) ====================
|
||||||
|
|
||||||
|
export const centralUserFields: FieldConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
apiName: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: FieldType.EMAIL,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'firstName',
|
||||||
|
apiName: 'firstName',
|
||||||
|
label: 'First Name',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lastName',
|
||||||
|
apiName: 'lastName',
|
||||||
|
label: 'Last Name',
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
apiName: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: FieldType.TEXT, // Will be treated as password in edit view
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: false,
|
||||||
|
showOnDetail: false,
|
||||||
|
showOnEdit: true,
|
||||||
|
helpText: 'Leave blank to keep existing password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role',
|
||||||
|
apiName: 'role',
|
||||||
|
label: 'Role',
|
||||||
|
type: FieldType.SELECT,
|
||||||
|
isRequired: true,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Admin', value: 'admin' },
|
||||||
|
{ label: 'Super Admin', value: 'superadmin' },
|
||||||
|
],
|
||||||
|
defaultValue: 'admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'isActive',
|
||||||
|
apiName: 'isActive',
|
||||||
|
label: 'Active',
|
||||||
|
type: FieldType.BOOLEAN,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: true,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
apiName: 'createdAt',
|
||||||
|
label: 'Created At',
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
showOnList: true,
|
||||||
|
showOnDetail: true,
|
||||||
|
showOnEdit: false,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const centralUserListConfig: ListViewConfig = {
|
||||||
|
objectApiName: 'User',
|
||||||
|
mode: ViewMode.LIST,
|
||||||
|
fields: centralUserFields,
|
||||||
|
pageSize: 25,
|
||||||
|
searchable: true,
|
||||||
|
filterable: true,
|
||||||
|
exportable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const centralUserDetailConfig: DetailViewConfig = {
|
||||||
|
objectApiName: 'User',
|
||||||
|
mode: ViewMode.DETAIL,
|
||||||
|
fields: centralUserFields,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'User Information',
|
||||||
|
fields: ['email', 'firstName', 'lastName', 'role', 'isActive'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System Information',
|
||||||
|
fields: ['createdAt'],
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const centralUserEditConfig: EditViewConfig = {
|
||||||
|
objectApiName: 'User',
|
||||||
|
mode: ViewMode.EDIT,
|
||||||
|
fields: centralUserFields,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'User Information',
|
||||||
|
fields: ['email', 'firstName', 'lastName'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Access & Security',
|
||||||
|
fields: ['password', 'role', 'isActive'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
161
frontend/pages/central/domains/[[recordId]]/[[view]].vue
Normal file
161
frontend/pages/central/domains/[[recordId]]/[[view]].vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useViewState } from '@/composables/useFieldViews'
|
||||||
|
import {
|
||||||
|
domainListConfig,
|
||||||
|
domainDetailConfig,
|
||||||
|
domainEditConfig,
|
||||||
|
} from '@/composables/useCentralEntities'
|
||||||
|
import ListView from '@/components/views/ListView.vue'
|
||||||
|
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||||
|
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const recordId = computed(() => route.params.recordId as string)
|
||||||
|
const view = computed(() => {
|
||||||
|
if (route.params.recordId === 'new' && !route.params.view) {
|
||||||
|
return 'edit'
|
||||||
|
}
|
||||||
|
return (route.params.view as 'list' | 'detail' | 'edit') || 'list'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use view state composable
|
||||||
|
const {
|
||||||
|
records,
|
||||||
|
currentRecord,
|
||||||
|
loading: dataLoading,
|
||||||
|
saving,
|
||||||
|
fetchRecords,
|
||||||
|
fetchRecord,
|
||||||
|
deleteRecords,
|
||||||
|
handleSave,
|
||||||
|
} = useViewState('/central/domains')
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleRowClick = (row: any) => {
|
||||||
|
router.push(`/central/domains/${row.id}/detail`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push(`/central/domains/new`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row?: any) => {
|
||||||
|
const id = row?.id || recordId.value
|
||||||
|
router.push(`/central/domains/${id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (view.value === 'detail') {
|
||||||
|
router.push('/central/domains')
|
||||||
|
} else if (view.value === 'edit') {
|
||||||
|
if (recordId.value && recordId.value !== 'new') {
|
||||||
|
router.push(`/central/domains/${recordId.value}/detail`)
|
||||||
|
} else {
|
||||||
|
router.push('/central/domains')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
handleBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (rows: any[]) => {
|
||||||
|
if (confirm(`Delete ${rows.length} domain(s)? This action cannot be undone.`)) {
|
||||||
|
try {
|
||||||
|
const ids = rows.map(r => r.id)
|
||||||
|
await deleteRecords(ids)
|
||||||
|
if (view.value !== 'list') {
|
||||||
|
await router.push('/central/domains')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to delete domains:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveRecord = async (data: any) => {
|
||||||
|
try {
|
||||||
|
const savedRecord = await handleSave(data)
|
||||||
|
if (savedRecord?.id) {
|
||||||
|
router.push(`/central/domains/${savedRecord.id}/detail`)
|
||||||
|
} else {
|
||||||
|
router.push('/central/domains')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to save domain:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(async () => {
|
||||||
|
if (view.value === 'list') {
|
||||||
|
await fetchRecords()
|
||||||
|
} else if (recordId.value && recordId.value !== 'new') {
|
||||||
|
await fetchRecord(recordId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<div class="object-view-container">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div v-if="view === 'list'" class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Domains</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">
|
||||||
|
Manage tenant domains and subdomain mappings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<ListView
|
||||||
|
v-if="view === 'list'"
|
||||||
|
:config="domainListConfig"
|
||||||
|
:data="records"
|
||||||
|
:loading="dataLoading"
|
||||||
|
selectable
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
@create="handleCreate"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Detail View -->
|
||||||
|
<DetailView
|
||||||
|
v-else-if="view === 'detail' && currentRecord"
|
||||||
|
:config="domainDetailConfig"
|
||||||
|
:data="currentRecord"
|
||||||
|
:loading="dataLoading"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="() => handleDelete([currentRecord])"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit View -->
|
||||||
|
<EditView
|
||||||
|
v-else-if="(view === 'edit' || recordId === 'new')"
|
||||||
|
:config="domainEditConfig"
|
||||||
|
:data="currentRecord || {}"
|
||||||
|
:loading="dataLoading"
|
||||||
|
:saving="saving"
|
||||||
|
@save="handleSaveRecord"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.object-view-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
frontend/pages/central/tenants/[[recordId]]/[[view]].vue
Normal file
162
frontend/pages/central/tenants/[[recordId]]/[[view]].vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useViewState } from '@/composables/useFieldViews'
|
||||||
|
import {
|
||||||
|
tenantFields,
|
||||||
|
tenantListConfig,
|
||||||
|
tenantDetailConfig,
|
||||||
|
tenantEditConfig,
|
||||||
|
} from '@/composables/useCentralEntities'
|
||||||
|
import ListView from '@/components/views/ListView.vue'
|
||||||
|
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||||
|
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const recordId = computed(() => route.params.recordId as string)
|
||||||
|
const view = computed(() => {
|
||||||
|
if (route.params.recordId === 'new' && !route.params.view) {
|
||||||
|
return 'edit'
|
||||||
|
}
|
||||||
|
return (route.params.view as 'list' | 'detail' | 'edit') || 'list'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use view state composable
|
||||||
|
const {
|
||||||
|
records,
|
||||||
|
currentRecord,
|
||||||
|
loading: dataLoading,
|
||||||
|
saving,
|
||||||
|
fetchRecords,
|
||||||
|
fetchRecord,
|
||||||
|
deleteRecords,
|
||||||
|
handleSave,
|
||||||
|
} = useViewState('/central/tenants')
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleRowClick = (row: any) => {
|
||||||
|
router.push(`/central/tenants/${row.id}/detail`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push(`/central/tenants/new`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row?: any) => {
|
||||||
|
const id = row?.id || recordId.value
|
||||||
|
router.push(`/central/tenants/${id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (view.value === 'detail') {
|
||||||
|
router.push('/central/tenants')
|
||||||
|
} else if (view.value === 'edit') {
|
||||||
|
if (recordId.value && recordId.value !== 'new') {
|
||||||
|
router.push(`/central/tenants/${recordId.value}/detail`)
|
||||||
|
} else {
|
||||||
|
router.push('/central/tenants')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
handleBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (rows: any[]) => {
|
||||||
|
if (confirm(`Delete ${rows.length} tenant(s)? This action cannot be undone.`)) {
|
||||||
|
try {
|
||||||
|
const ids = rows.map(r => r.id)
|
||||||
|
await deleteRecords(ids)
|
||||||
|
if (view.value !== 'list') {
|
||||||
|
await router.push('/central/tenants')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to delete tenants:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveRecord = async (data: any) => {
|
||||||
|
try {
|
||||||
|
const savedRecord = await handleSave(data)
|
||||||
|
if (savedRecord?.id) {
|
||||||
|
router.push(`/central/tenants/${savedRecord.id}/detail`)
|
||||||
|
} else {
|
||||||
|
router.push('/central/tenants')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to save tenant:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(async () => {
|
||||||
|
if (view.value === 'list') {
|
||||||
|
await fetchRecords()
|
||||||
|
} else if (recordId.value && recordId.value !== 'new') {
|
||||||
|
await fetchRecord(recordId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<div class="object-view-container">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div v-if="view === 'list'" class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Tenants</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">
|
||||||
|
Manage tenant organizations and their database configurations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<ListView
|
||||||
|
v-if="view === 'list'"
|
||||||
|
:config="tenantListConfig"
|
||||||
|
:data="records"
|
||||||
|
:loading="dataLoading"
|
||||||
|
selectable
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
@create="handleCreate"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Detail View -->
|
||||||
|
<DetailView
|
||||||
|
v-else-if="view === 'detail' && currentRecord"
|
||||||
|
:config="tenantDetailConfig"
|
||||||
|
:data="currentRecord"
|
||||||
|
:loading="dataLoading"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="() => handleDelete([currentRecord])"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit View -->
|
||||||
|
<EditView
|
||||||
|
v-else-if="(view === 'edit' || recordId === 'new') && tenantEditConfig"
|
||||||
|
:config="tenantEditConfig"
|
||||||
|
:data="currentRecord || {}"
|
||||||
|
:loading="dataLoading"
|
||||||
|
:saving="saving"
|
||||||
|
@save="handleSaveRecord"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.object-view-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
frontend/pages/central/users/[[recordId]]/[[view]].vue
Normal file
166
frontend/pages/central/users/[[recordId]]/[[view]].vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useViewState } from '@/composables/useFieldViews'
|
||||||
|
import {
|
||||||
|
centralUserListConfig,
|
||||||
|
centralUserDetailConfig,
|
||||||
|
centralUserEditConfig,
|
||||||
|
} from '@/composables/useCentralEntities'
|
||||||
|
import ListView from '@/components/views/ListView.vue'
|
||||||
|
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||||
|
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const recordId = computed(() => route.params.recordId as string)
|
||||||
|
const view = computed(() => {
|
||||||
|
if (route.params.recordId === 'new' && !route.params.view) {
|
||||||
|
return 'edit'
|
||||||
|
}
|
||||||
|
return (route.params.view as 'list' | 'detail' | 'edit') || 'list'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use view state composable
|
||||||
|
const {
|
||||||
|
records,
|
||||||
|
currentRecord,
|
||||||
|
loading: dataLoading,
|
||||||
|
saving,
|
||||||
|
fetchRecords,
|
||||||
|
fetchRecord,
|
||||||
|
deleteRecords,
|
||||||
|
handleSave,
|
||||||
|
} = useViewState('/central/users')
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleRowClick = (row: any) => {
|
||||||
|
router.push(`/central/users/${row.id}/detail`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push(`/central/users/new`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row?: any) => {
|
||||||
|
const id = row?.id || recordId.value
|
||||||
|
router.push(`/central/users/${id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (view.value === 'detail') {
|
||||||
|
router.push('/central/users')
|
||||||
|
} else if (view.value === 'edit') {
|
||||||
|
if (recordId.value && recordId.value !== 'new') {
|
||||||
|
router.push(`/central/users/${recordId.value}/detail`)
|
||||||
|
} else {
|
||||||
|
router.push('/central/users')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
handleBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (rows: any[]) => {
|
||||||
|
if (confirm(`Delete ${rows.length} user(s)? This action cannot be undone.`)) {
|
||||||
|
try {
|
||||||
|
const ids = rows.map(r => r.id)
|
||||||
|
await deleteRecords(ids)
|
||||||
|
if (view.value !== 'list') {
|
||||||
|
await router.push('/central/users')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to delete users:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveRecord = async (data: any) => {
|
||||||
|
try {
|
||||||
|
// Remove password if empty (to keep existing password)
|
||||||
|
if (data.password === '' || data.password === null) {
|
||||||
|
delete data.password
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedRecord = await handleSave(data)
|
||||||
|
if (savedRecord?.id) {
|
||||||
|
router.push(`/central/users/${savedRecord.id}/detail`)
|
||||||
|
} else {
|
||||||
|
router.push('/central/users')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to save user:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(async () => {
|
||||||
|
if (view.value === 'list') {
|
||||||
|
await fetchRecords()
|
||||||
|
} else if (recordId.value && recordId.value !== 'new') {
|
||||||
|
await fetchRecord(recordId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<div class="object-view-container">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div v-if="view === 'list'" class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Admin Users</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">
|
||||||
|
Manage central administrator accounts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<ListView
|
||||||
|
v-if="view === 'list'"
|
||||||
|
:config="centralUserListConfig"
|
||||||
|
:data="records"
|
||||||
|
:loading="dataLoading"
|
||||||
|
selectable
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
@create="handleCreate"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Detail View -->
|
||||||
|
<DetailView
|
||||||
|
v-else-if="view === 'detail' && currentRecord"
|
||||||
|
:config="centralUserDetailConfig"
|
||||||
|
:data="currentRecord"
|
||||||
|
:loading="dataLoading"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="() => handleDelete([currentRecord])"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit View -->
|
||||||
|
<EditView
|
||||||
|
v-else-if="(view === 'edit' || recordId === 'new')"
|
||||||
|
:config="centralUserEditConfig"
|
||||||
|
:data="currentRecord || {}"
|
||||||
|
:loading="dataLoading"
|
||||||
|
:saving="saving"
|
||||||
|
@save="handleSaveRecord"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.object-view-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user