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 { 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,
|
||||
|
||||
@@ -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<any[]>([])
|
||||
const topLevelObjects = ref<any[]>([])
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -160,6 +202,53 @@ const staticMenuItems = [
|
||||
</SidebarGroupContent>
|
||||
</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) -->
|
||||
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
|
||||
<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