5 Commits

Author SHA1 Message Date
Francisco Gaona
2bc672e4c5 WIP - some fixes 2025-12-24 10:54:19 +01:00
Francisco Gaona
962c84e6d2 WIP - fix lookup field 2025-12-24 00:05:15 +01:00
Francisco Gaona
fc1bec4de7 WIP - related lists and look up field 2025-12-23 23:59:04 +01:00
Francisco Gaona
0275b96014 WIP - central operations 2025-12-23 23:38:45 +01:00
Francisco Gaona
e4f3bad971 WIp - fix login into central 2025-12-23 22:16:58 +01:00
25 changed files with 2735 additions and 29 deletions

View File

@@ -8,3 +8,6 @@ REDIS_URL="redis://redis:6379"
# JWT, multi-tenant hints, etc.
JWT_SECRET="devsecret"
TENANCY_STRATEGY="single-db"
CENTRAL_SUBDOMAINS="central,admin"

231
CENTRAL_ADMIN_AUTH_GUIDE.md Normal file
View File

@@ -0,0 +1,231 @@
# Central Admin Authentication Guide
## Overview
The platform now supports **two types of authentication**:
1. **Tenant Login** - Authenticates users against a specific tenant's database
2. **Central Admin Login** - Authenticates administrators against the central platform database
## Central vs Tenant Authentication
### Tenant Authentication (Default)
- Users login to their specific tenant database
- Each tenant has isolated user tables
- Access is scoped to the tenant's data
- API Endpoint: `/api/auth/login`
- Requires `x-tenant-id` header or subdomain detection
### Central Admin Authentication
- Administrators login to the central platform database
- Can manage all tenants and platform-wide features
- Users stored in the central database `users` table
- API Endpoint: `/api/central/auth/login`
- No tenant ID required
## Creating a Central Admin User
### Quick Start
```bash
cd backend
npm run create-central-admin
```
Follow the interactive prompts to create your admin user.
### Environment Variable Method
```bash
EMAIL=admin@platform.com \
PASSWORD=SecureP@ssw0rd \
FIRST_NAME=Admin \
LAST_NAME=User \
ROLE=superadmin \
npm run create-central-admin
```
### Role Types
- **admin** - Standard administrator with platform management access
- **superadmin** - Super administrator with full platform access
## Logging In as Central Admin
### Frontend Login
1. Navigate to the login page (`/login`)
2. **Check the "Login as Central Admin" checkbox**
3. Enter your central admin email and password
4. Click "Login to Central"
The checkbox toggles between:
-**Checked** - Authenticates against central database
-**Unchecked** - Authenticates against tenant database (default)
### API Login (Direct)
**Central Admin Login:**
```bash
curl -X POST http://localhost:3000/api/central/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@platform.com",
"password": "SecureP@ssw0rd"
}'
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "cm5a1b2c3d4e5f6g7h8i9j0k",
"email": "admin@platform.com",
"firstName": "Admin",
"lastName": "User",
"role": "superadmin",
"isCentralAdmin": true
}
}
```
**Tenant Login (for comparison):**
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-H "x-tenant-id: tenant1" \
-d '{
"email": "user@tenant1.com",
"password": "password123"
}'
```
## JWT Token Differences
### Central Admin Token Payload
```json
{
"sub": "user-id",
"email": "admin@platform.com",
"isCentralAdmin": true,
"iat": 1234567890,
"exp": 1234654290
}
```
### Tenant User Token Payload
```json
{
"sub": "user-id",
"email": "user@tenant1.com",
"iat": 1234567890,
"exp": 1234654290
}
```
The `isCentralAdmin` flag in the JWT can be used to determine if the user is a central admin.
## Database Schema
### Central Database - `users` Table
```sql
CREATE TABLE users (
id VARCHAR(30) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
firstName VARCHAR(100),
lastName VARCHAR(100),
role VARCHAR(50) DEFAULT 'admin',
isActive BOOLEAN DEFAULT true,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### Tenant Database - `users` Table
Tenant databases have their own separate `users` table with similar structure but tenant-specific users.
## Security Considerations
1. **Separate Password Storage** - Central admin passwords are stored separately from tenant user passwords
2. **Role-Based Access** - Central admins have different permissions than tenant users
3. **JWT Identification** - The `isCentralAdmin` flag helps identify admin users
4. **Encryption** - All passwords are hashed using bcrypt with salt rounds
## Common Use Cases
### Platform Administration
- **Login as:** Central Admin
- **Can do:**
- Create/manage tenants
- View all tenant information
- Manage platform-wide settings
- Access tenant provisioning APIs
### Tenant Management
- **Login as:** Tenant User
- **Can do:**
- Access tenant-specific data
- Manage records within the tenant
- Use tenant applications
- Limited to tenant scope
## Troubleshooting
### "Tenant ID is required" Error
- You're trying to login to tenant endpoint without tenant ID
- Solution: Either provide `x-tenant-id` header or use central admin login
### "Invalid credentials" with Central Login
- Check that you're using the "Login as Central Admin" checkbox
- Verify the user exists in the central database
- Use the script to create a central admin if needed
### "User already exists"
- A central admin with that email already exists
- Use a different email or reset the existing user's password
## Architecture
```
┌─────────────────────────────────────────┐
│ Frontend Login Form │
│ ┌────────────────────────────────────┐ │
│ │ ☑ Login as Central Admin │ │
│ └────────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
┌───────┴────────┐
│ Checked? │
└───────┬────────┘
┌──────────┴──────────┐
│ │
▼ ▼
/api/central/auth/login /api/auth/login
│ │
▼ ▼
Central Database Tenant Database
(users table) (users table)
```
## API Endpoints Summary
| Endpoint | Purpose | Requires Tenant ID | Database |
|----------|---------|-------------------|----------|
| `POST /api/central/auth/login` | Central admin login | ❌ No | Central |
| `POST /api/central/auth/register` | Create central admin | ❌ No | Central |
| `POST /api/auth/login` | Tenant user login | ✅ Yes | Tenant |
| `POST /api/auth/register` | Create tenant user | ✅ Yes | Tenant |
## Next Steps
1. Create your first central admin user
2. Login with the central admin checkbox enabled
3. Access platform administration features
4. Manage tenants and platform settings
For tenant management and provisioning, see [TENANT_MIGRATION_GUIDE.md](../TENANT_MIGRATION_GUIDE.md).

130
CENTRAL_LOGIN.md Normal file
View File

@@ -0,0 +1,130 @@
# Central Admin Login
## Overview
The platform supports seamless authentication for both **tenant users** and **central administrators** using the same login endpoint. The system automatically determines which database to authenticate against based on the subdomain.
## How It Works
### Subdomain-Based Routing
The authentication flow uses subdomain detection to determine the authentication context:
1. **Central Subdomains** (e.g., `central.yourdomain.com`, `admin.yourdomain.com`)
- Authenticates against the **central database**
- Used for platform administrators
- Configured via `CENTRAL_SUBDOMAINS` environment variable
2. **Tenant Subdomains** (e.g., `acme.yourdomain.com`, `client1.yourdomain.com`)
- Authenticates against the **tenant's database**
- Used for regular tenant users
- Each tenant has its own isolated database
### Configuration
Set the central subdomains in your `.env` file:
```bash
# Comma-separated list of subdomains that access the central database
CENTRAL_SUBDOMAINS="central,admin"
```
### Implementation Details
#### 1. Tenant Middleware (`tenant.middleware.ts`)
The middleware extracts the subdomain from the request and:
- Checks if it matches a central subdomain
- If yes: Skips tenant resolution and attaches subdomain to request
- If no: Resolves the tenant ID from the subdomain and attaches it to request
#### 2. Auth Service (`auth.service.ts`)
The auth service has branching logic in `validateUser()` and `register()`:
- Checks if the subdomain is in the central list
- Routes to `validateCentralUser()` or normal tenant user validation
- Central users are authenticated against the `central` database
- Tenant users are authenticated against their tenant's database
#### 3. Auth Controller (`auth.controller.ts`)
The controller:
- Extracts subdomain from the request
- Validates tenant ID requirement (not needed for central subdomains)
- Passes subdomain to auth service for proper routing
## Usage
### Creating a Central Admin User
```bash
cd backend
npm run create-central-admin
```
Follow the prompts to enter:
- Email
- Password
- First Name (optional)
- Last Name (optional)
### Logging In as Central Admin
1. Navigate to `central.yourdomain.com` (or whatever central subdomain you configured)
2. Enter your central admin email and password
3. You'll be authenticated against the central database
**No special UI elements needed** - the system automatically detects the subdomain!
### Logging In as Tenant User
1. Navigate to `yourtenantslug.yourdomain.com`
2. Enter your tenant user credentials
3. You'll be authenticated against that tenant's database
## Architecture Benefits
**Transparent to Frontend** - No need for special "login as admin" checkboxes or UI elements
**Secure** - Central and tenant authentication are completely separated
**Scalable** - Easy to add more central subdomains by updating environment variable
**Clean Code** - Single auth controller/service with clear branching logic
**Flexible** - Can be used for both development (localhost) and production
## Local Development
For local development, you can:
1. **Use subdomain on localhost:**
```
central.localhost:3000
acme.localhost:3000
```
2. **Use x-tenant-id header** (for tenant-specific requests):
```bash
curl -H "x-tenant-id: acme-corp" http://localhost:3000/api/auth/login
```
3. **For central admin, use central subdomain:**
```bash
curl http://central.localhost:3000/api/auth/login
```
## Database Schema
### Central Database (`User` model)
- Stores platform administrators
- Prisma schema: `schema-central.prisma`
- Fields: id, email, password, firstName, lastName, isActive, createdAt, updatedAt
### Tenant Database (`users` table)
- Stores tenant-specific users
- Knex migrations: `migrations/tenant/`
- Fields: id, email, password, firstName, lastName, isActive, created_at, updated_at
## Security Considerations
- Central admin credentials are never stored in tenant databases
- Tenant user credentials are never stored in the central database
- JWT tokens include user context (tenant ID or central admin flag)
- Subdomain validation prevents unauthorized access

View File

@@ -0,0 +1,219 @@
# Related Lists and Lookup Fields Implementation
This document describes the implementation of related lists and improved relationship field handling in the application.
## Features Implemented
### 1. Related Lists Component (`/frontend/components/RelatedList.vue`)
A reusable component that displays related records for a parent entity in a table format.
**Features:**
- Displays related records in a formatted table
- Shows configurable fields for each related record
- Supports navigation to related record detail pages
- Allows creating new related records
- Handles loading and error states
- Empty state with call-to-action button
- Automatically fetches related records or uses provided data
**Usage Example:**
```vue
<RelatedList
:config="{
title: 'Domains',
relationName: 'domains',
objectApiName: 'domains',
fields: [...],
canCreate: true
}"
:parent-id="tenantId"
:related-records="tenant.domains"
@navigate="handleNavigate"
@create="handleCreate"
/>
```
### 2. Lookup Field Component (`/frontend/components/fields/LookupField.vue`)
A searchable dropdown component for selecting related records (belongs-to relationships).
**Features:**
- Searchable combobox for finding records
- Fetches available records from API
- Displays meaningful field names instead of UUIDs
- Clear button to remove selection
- Configurable relation object and display field
- Loading states
**Usage:**
```vue
<LookupField
:field="{
type: FieldType.BELONGS_TO,
relationObject: 'tenants',
relationDisplayField: 'name',
...
}"
v-model="domainData.tenantId"
base-url="/api/central"
/>
```
### 3. Enhanced Field Renderer (`/frontend/components/fields/FieldRenderer.vue`)
Updated to handle relationship fields intelligently.
**New Features:**
- Detects BELONGS_TO field type
- Fetches related record for display in detail/list views
- Shows meaningful name instead of UUID
- Uses LookupField component for editing
- Automatic loading of related record data
**Behavior:**
- **Detail/List View:** Fetches and displays related record name
- **Edit View:** Renders LookupField for selection
- Falls back to UUID if related record can't be fetched
### 4. Enhanced Detail View (`/frontend/components/views/DetailView.vue`)
Added support for displaying related lists below the main record details.
**New Features:**
- `relatedLists` configuration support
- Emits `navigate` and `createRelated` events
- Passes related records data to RelatedList components
- Automatically displays all configured related lists
### 5. Type Definitions (`/frontend/types/field-types.ts`)
Added new types for related list configuration:
```typescript
export interface RelatedListConfig {
title: string;
relationName: string; // Property name on parent object
objectApiName: string; // API endpoint name
fields: FieldConfig[]; // Fields to display in list
canCreate?: boolean;
createRoute?: string;
}
export interface DetailViewConfig extends ViewConfig {
mode: ViewMode.DETAIL;
sections?: FieldSection[];
actions?: ViewAction[];
relatedLists?: RelatedListConfig[]; // NEW
}
```
### 6. Backend Support (`/backend/src/tenant/central-admin.controller.ts`)
Added filtering support for fetching related records.
**Enhancement:**
```typescript
@Get('domains')
async getDomains(
@Req() req: any,
@Query('parentId') parentId?: string,
@Query('tenantId') tenantId?: string,
) {
// ...
if (parentId || tenantId) {
query = query.where('tenantId', parentId || tenantId);
}
return query;
}
```
### 7. Central Entities Configuration (`/frontend/composables/useCentralEntities.ts`)
Added related list configurations to tenant detail view:
```typescript
export const tenantDetailConfig: DetailViewConfig = {
// ... existing config
relatedLists: [
{
title: 'Domains',
relationName: 'domains',
objectApiName: 'domains',
fields: [
{ id: 'domain', apiName: 'domain', label: 'Domain', type: FieldType.TEXT },
{ id: 'isPrimary', apiName: 'isPrimary', label: 'Primary', type: FieldType.BOOLEAN },
{ id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME },
],
canCreate: true,
},
],
}
```
Updated domain field configuration to use lookup:
```typescript
{
id: 'tenantId',
apiName: 'tenantId',
label: 'Tenant',
type: FieldType.BELONGS_TO, // Changed from TEXT
relationObject: 'tenants',
relationDisplayField: 'name',
// ...
}
```
## User Experience Improvements
### Before:
- **Relationship Fields:** Displayed raw UUIDs everywhere
- **Editing Relationships:** Had to manually enter or paste UUIDs
- **Related Records:** No way to see child records from parent detail page
- **Navigation:** Had to manually navigate to related record lists
### After:
- **Relationship Fields:** Show meaningful names (e.g., "Acme Corp" instead of "abc-123-def")
- **Editing Relationships:** Searchable dropdown with all available options
- **Related Records:** Automatically displayed in related lists on detail pages
- **Navigation:** One-click navigation to related records; create button with parent context pre-filled
## Example: Tenant Detail View
When viewing a tenant, users now see:
1. **Main tenant information** (name, slug, status, database config)
2. **Related Lists section** below main details:
- **Domains list** showing all domains for this tenant
- Each domain row displays: domain name, isPrimary flag, created date
- "New" button to create domain with tenantId pre-filled
- Click any domain to navigate to its detail page
## Example: Creating a Domain
When creating/editing a domain:
1. **Tenant field** shows a searchable dropdown instead of text input
2. Type to search available tenants by name
3. Select from list - shows "Acme Corp" not "uuid-123"
4. Selected tenant's name is displayed
5. Can clear selection with X button
## Technical Notes
- All API calls use the centralized `$api` helper from `useNuxtApp()`
- Type casting via `unknown` to handle NuxtApp type issues
- Filter functions use TypeScript type predicates for proper type narrowing
- Related records can be passed in (if already fetched with parent) or fetched separately
- Backend supports both `parentId` and specific relationship field names (e.g., `tenantId`)
## Future Enhancements
Potential additions:
- Inline editing within related lists
- Pagination for large related lists
- Sorting and filtering within related lists
- Bulk operations on related records
- Many-to-many relationship support
- Has-many relationship support with junction tables

View File

@@ -18,3 +18,6 @@ JWT_EXPIRES_IN="7d"
# Application
NODE_ENV="development"
PORT="3000"
# Central Admin Subdomains (comma-separated list of subdomains that access the central database)
CENTRAL_SUBDOMAINS="central,admin"

View File

@@ -1,8 +1,53 @@
# Tenant Migration Scripts
# Tenant Migration & Admin Scripts
This directory contains scripts for managing database migrations across all tenants in the multi-tenant platform.
This directory contains scripts for managing database migrations across all tenants and creating admin users in the multi-tenant platform.
## Available Scripts
## Admin User Management
### Create Central Admin User
```bash
npm run create-central-admin
```
Creates an administrator user in the **central database**. Central admins can:
- Manage tenants (create, update, delete)
- Access platform-wide administration features
- View all tenant information
- Manage tenant provisioning
**Interactive Mode:**
```bash
npm run create-central-admin
# You will be prompted for:
# - Email
# - Password
# - First Name (optional)
# - Last Name (optional)
# - Role (admin or superadmin)
```
**Non-Interactive Mode (using environment variables):**
```bash
EMAIL=admin@example.com PASSWORD=securepass123 FIRST_NAME=John LAST_NAME=Doe ROLE=superadmin npm run create-central-admin
```
**Logging In as Central Admin:**
1. Access the application using a central subdomain (e.g., `central.yourdomain.com` or `admin.yourdomain.com`)
2. Enter your central admin credentials
3. You'll be authenticated against the central database (not a tenant database)
**Note:** The system automatically detects if you're logging in from a central subdomain based on the `CENTRAL_SUBDOMAINS` environment variable (defaults to `central,admin`). No special UI or configuration is needed on the frontend.
### Create Tenant User
For creating users within a specific tenant database, use:
```bash
npm run create-tenant-user <tenant-slug>
# (Note: This script may need to be created or already exists)
```
## Migration Scripts
### 1. Create a New Migration

View File

@@ -5,6 +5,7 @@ import {
UnauthorizedException,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { AuthService } from './auth.service';
@@ -40,17 +41,36 @@ class RegisterDto {
export class AuthController {
constructor(private authService: AuthService) {}
private isCentralSubdomain(subdomain: string): boolean {
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
return centralSubdomains.includes(subdomain);
}
@HttpCode(HttpStatus.OK)
@Post('login')
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
async login(
@TenantId() tenantId: string,
@Body() loginDto: LoginDto,
@Req() req: any,
) {
const subdomain = req.raw?.subdomain;
console.log('subdomain:' + subdomain);
console.log('CENTRAL_SUBDOMAINS:', process.env.CENTRAL_SUBDOMAINS);
// If it's a central subdomain, tenantId is not required
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
}
const user = await this.authService.validateUser(
tenantId,
loginDto.email,
loginDto.password,
subdomain,
);
if (!user) {
@@ -64,9 +84,15 @@ export class AuthController {
async register(
@TenantId() tenantId: string,
@Body() registerDto: RegisterDto,
@Req() req: any,
) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
const subdomain = req.raw?.subdomain;
// If it's a central subdomain, tenantId is not required
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
}
const user = await this.authService.register(
@@ -75,6 +101,7 @@ export class AuthController {
registerDto.password,
registerDto.firstName,
registerDto.lastName,
subdomain,
);
return user;

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import * as bcrypt from 'bcrypt';
@Injectable()
@@ -10,11 +11,24 @@ export class AuthService {
private jwtService: JwtService,
) {}
private isCentralSubdomain(subdomain: string): boolean {
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
return centralSubdomains.includes(subdomain);
}
async validateUser(
tenantId: string,
email: string,
password: string,
subdomain?: string,
): Promise<any> {
// Check if this is a central subdomain
if (subdomain && this.isCentralSubdomain(subdomain)) {
return this.validateCentralUser(email, password);
}
// Otherwise, validate as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const user = await tenantDb('users')
@@ -43,6 +57,31 @@ export class AuthService {
return null;
}
private async validateCentralUser(
email: string,
password: string,
): Promise<any> {
const centralPrisma = getCentralPrisma();
const user = await centralPrisma.user.findUnique({
where: { email },
});
if (!user) {
return null;
}
if (await bcrypt.compare(password, user.password)) {
const { password: _, ...result } = user;
return {
...result,
isCentralAdmin: true,
};
}
return null;
}
async login(user: any) {
const payload = {
sub: user.id,
@@ -66,7 +105,14 @@ export class AuthService {
password: string,
firstName?: string,
lastName?: string,
subdomain?: string,
) {
// Check if this is a central subdomain
if (subdomain && this.isCentralSubdomain(subdomain)) {
return this.registerCentralUser(email, password, firstName, lastName);
}
// Otherwise, register as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const hashedPassword = await bcrypt.hash(password, 10);
@@ -88,4 +134,28 @@ export class AuthService {
const { password: _, ...result } = user;
return result;
}
private async registerCentralUser(
email: string,
password: string,
firstName?: string,
lastName?: string,
) {
const centralPrisma = getCentralPrisma();
const hashedPassword = await bcrypt.hash(password, 10);
const user = await centralPrisma.user.create({
data: {
email,
password: hashedPassword,
firstName: firstName || null,
lastName: lastName || null,
isActive: true,
},
});
const { password: _, ...result } = user;
return result;
}
}

View 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();
}
}

View File

@@ -0,0 +1,270 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
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,
@Query('parentId') parentId?: string,
@Query('tenantId') tenantId?: string,
) {
this.checkCentralAdmin(req);
let query = CentralDomain.query().withGraphFetched('tenant');
// Filter by parent/tenant ID if provided (for related lists)
if (parentId || tenantId) {
query = query.where('tenantId', parentId || tenantId);
}
return query;
}
@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 };
}
}

View 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);
}

View File

@@ -17,9 +17,14 @@ export class TenantMiddleware implements NestMiddleware {
// Extract subdomain from hostname
const host = req.headers.host || '';
const hostname = host.split(':')[0]; // Remove port if present
const parts = hostname.split('.');
// Check Origin header to get frontend subdomain (for API calls)
const origin = req.headers.origin as string;
const referer = req.headers.referer as string;
let parts = hostname.split('.');
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}`);
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`);
// For local development, accept x-tenant-id header
let tenantId = req.headers['x-tenant-id'] as string;
@@ -27,12 +32,26 @@ export class TenantMiddleware implements NestMiddleware {
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
// If x-tenant-id is explicitly provided, use it directly
if (tenantId) {
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
(req as any).tenantId = tenantId;
next();
return;
// Try to extract subdomain from Origin header first (for API calls from frontend)
if (origin) {
try {
const originUrl = new URL(origin);
const originHost = originUrl.hostname;
parts = originHost.split('.');
this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`);
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
} else if (referer && !tenantId) {
// Fallback to Referer if no Origin
try {
const refererUrl = new URL(referer);
const refererHost = refererUrl.hostname;
parts = refererHost.split('.');
this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`);
} catch (error) {
this.logger.warn(`Failed to parse referer: ${referer}`);
}
}
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
@@ -51,6 +70,36 @@ export class TenantMiddleware implements NestMiddleware {
this.logger.log(`Extracted subdomain: ${subdomain}`);
// Always attach subdomain to request if present
if (subdomain) {
(req as any).subdomain = subdomain;
}
// If x-tenant-id is explicitly provided, use it directly but still keep subdomain
if (tenantId) {
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
(req as any).tenantId = tenantId;
next();
return;
}
// Always attach subdomain to request if present
if (subdomain) {
(req as any).subdomain = subdomain;
}
// Check if this is a central subdomain
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
const isCentral = subdomain && centralSubdomains.includes(subdomain);
// If it's a central subdomain, skip tenant resolution
if (isCentral) {
this.logger.log(`Central subdomain detected: ${subdomain}, skipping tenant resolution`);
(req as any).subdomain = subdomain;
next();
return;
}
// Get tenant by subdomain if available
if (subdomain) {
try {
@@ -72,9 +121,6 @@ export class TenantMiddleware implements NestMiddleware {
if (tenantId) {
// Attach tenant info to request object
(req as any).tenantId = tenantId;
if (subdomain) {
(req as any).subdomain = subdomain;
}
} else {
this.logger.warn(`No tenant identified from host: ${hostname}`);
}

View File

@@ -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,

View File

@@ -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,31 @@ const handleLogout = async () => {
await logout()
}
// Check if user is central admin (by checking if we're on a central subdomain)
// Use ref instead of computed to avoid hydration mismatch
const isCentralAdmin = ref(false)
// Fetch objects and group by app
const apps = ref<any[]>([])
const topLevelObjects = ref<any[]>([])
const loading = ref(true)
onMounted(async () => {
// Set isCentralAdmin first
if (process.client) {
const hostname = window.location.hostname
const parts = hostname.split('.')
const subdomain = parts.length >= 2 ? parts[0] : null
const centralSubdomains = ['central', 'admin']
isCentralAdmin.value = subdomain ? centralSubdomains.includes(subdomain) : false
}
// 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 +108,39 @@ const staticMenuItems = [
],
},
]
const centralAdminMenuItems: Array<{
title: string
icon: any
url?: string
items?: Array<{
title: string
url: string
icon: any
}>
}> = [
{
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 +212,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-if="item.items" 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>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, ExternalLink } from 'lucide-vue-next'
import type { FieldConfig } from '@/types/field-types'
interface RelatedListConfig {
title: string
relationName: string // e.g., 'domains', 'users'
objectApiName: string // e.g., 'domains', 'users'
fields: FieldConfig[] // Fields to display in the list
canCreate?: boolean
createRoute?: string // Route to create new related record
}
interface Props {
config: RelatedListConfig
parentId: string
relatedRecords?: any[] // Can be passed in if already fetched
baseUrl?: string // Base API URL, defaults to '/central'
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/central',
relatedRecords: undefined,
})
const emit = defineEmits<{
'navigate': [objectApiName: string, recordId: string]
'create': [objectApiName: string, parentId: string]
}>()
const { api } = useApi()
const records = ref<any[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Use provided records or fetch them
const displayRecords = computed(() => {
return props.relatedRecords || records.value
})
const fetchRelatedRecords = async () => {
if (props.relatedRecords) {
// Records already provided, no need to fetch
return
}
loading.value = true
error.value = null
try {
const response = await api.get(`${props.baseUrl}/${props.config.objectApiName}`, {
params: {
parentId: props.parentId,
},
})
records.value = response || []
} catch (err: any) {
console.error('Error fetching related records:', err)
error.value = err.message || 'Failed to fetch related records'
} finally {
loading.value = false
}
}
const handleCreateNew = () => {
emit('create', props.config.objectApiName, props.parentId)
}
const handleViewRecord = (recordId: string) => {
emit('navigate', props.config.objectApiName, recordId)
}
const formatValue = (value: any, field: FieldConfig): string => {
if (value === null || value === undefined) return '-'
// Handle different field types
if (field.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (field.type === 'datetime') {
return new Date(value).toLocaleString()
}
if (field.type === 'boolean') {
return value ? 'Yes' : 'No'
}
if (field.type === 'select' && field.options) {
const option = field.options.find(opt => opt.value === value)
return option?.label || value
}
return String(value)
}
onMounted(() => {
fetchRelatedRecords()
})
</script>
<template>
<Card class="related-list">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>{{ config.title }}</CardTitle>
<CardDescription v-if="displayRecords.length > 0">
{{ displayRecords.length }} {{ displayRecords.length === 1 ? 'record' : 'records' }}
</CardDescription>
</div>
<Button
v-if="config.canCreate !== false"
size="sm"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
New
</Button>
</div>
</CardHeader>
<CardContent>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-sm text-destructive py-4">
{{ error }}
</div>
<!-- Empty State -->
<div v-else-if="displayRecords.length === 0" class="text-center py-8 text-muted-foreground">
<p class="text-sm">No {{ config.title.toLowerCase() }} yet</p>
<Button
v-if="config.canCreate !== false"
variant="outline"
size="sm"
class="mt-4"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
Create First {{ config.title.slice(0, -1) }}
</Button>
</div>
<!-- Records Table -->
<div v-else class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead v-for="field in config.fields" :key="field.id">
{{ field.label }}
</TableHead>
<TableHead class="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in displayRecords" :key="record.id">
<TableCell v-for="field in config.fields" :key="field.id">
{{ formatValue(record[field.apiName], field) }}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
@click="handleViewRecord(record.id)"
>
<ExternalLink class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</template>
<style scoped>
.related-list {
margin-top: 1.5rem;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -9,19 +9,31 @@ import { DatePicker } from '@/components/ui/date-picker'
import { Badge } from '@/components/ui/badge'
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
import { Label } from '@/components/ui/label'
import LookupField from '@/components/fields/LookupField.vue'
interface Props {
field: FieldConfig
modelValue: any
mode: ViewMode
readonly?: boolean
baseUrl?: string // Base URL for API calls
recordData?: any // Full record data to access related objects
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/central',
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const { api } = useApi()
// For relationship fields, store the related record for display
const relatedRecord = ref<any | null>(null)
const loadingRelated = ref(false)
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
@@ -32,10 +44,88 @@ const isEditMode = computed(() => props.mode === ViewMode.EDIT)
const isListMode = computed(() => props.mode === ViewMode.LIST)
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
// Check if field is a relationship field
const isRelationshipField = computed(() => {
return [FieldType.BELONGS_TO].includes(props.field.type)
})
// Get relation object name (e.g., 'tenants' -> singular 'tenant')
const getRelationPropertyName = () => {
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant')
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject
}
// Fetch related record for display
const fetchRelatedRecord = async () => {
if (!isRelationshipField.value || !props.modelValue) return
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
const displayField = props.field.relationDisplayField || 'name'
loadingRelated.value = true
try {
const record = await api.get(`${props.baseUrl}/${relationObject}/${props.modelValue}`)
relatedRecord.value = record
} catch (err) {
console.error('Error fetching related record:', err)
relatedRecord.value = null
} finally {
loadingRelated.value = false
}
}
// Display value for relationship fields
const relationshipDisplayValue = computed(() => {
if (!isRelationshipField.value) return props.modelValue || '-'
// First, check if the parent record data includes the related object
// This happens when backend uses .withGraphFetched()
if (props.recordData) {
const relationPropertyName = getRelationPropertyName()
const relatedObject = props.recordData[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = props.field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || props.modelValue
}
}
// Otherwise use the fetched related record
if (relatedRecord.value) {
const displayField = props.field.relationDisplayField || 'name'
return relatedRecord.value[displayField] || relatedRecord.value.id
}
// Show loading state
if (loadingRelated.value) {
return 'Loading...'
}
// Fallback to ID
return props.modelValue || '-'
})
// Watch for changes in modelValue for relationship fields
watch(() => props.modelValue, () => {
if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
// Load related record on mount if needed
onMounted(() => {
if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
const formatValue = (val: any): string => {
if (val === null || val === undefined) return '-'
switch (props.field.type) {
case FieldType.BELONGS_TO:
return relationshipDisplayValue.value
case FieldType.DATE:
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
case FieldType.DATETIME:
@@ -113,9 +203,17 @@ const formatValue = (val: any): string => {
<!-- Edit View - Input components -->
<div v-else-if="isEditMode && !isReadOnly">
<!-- Relationship Field - Lookup -->
<LookupField
v-if="field.type === FieldType.BELONGS_TO"
:field="field"
v-model="value"
:base-url="baseUrl"
/>
<!-- Text Input -->
<Input
v-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
v-else-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
:id="field.id"
v-model="value"
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Check, ChevronsUpDown, X } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import type { FieldConfig } from '@/types/field-types'
interface Props {
field: FieldConfig
modelValue: string | null // The ID of the selected record
readonly?: boolean
baseUrl?: string // Base API URL, defaults to '/central'
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/central',
modelValue: null,
})
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const { api } = useApi()
const open = ref(false)
const searchQuery = ref('')
const records = ref<any[]>([])
const loading = ref(false)
const selectedRecord = ref<any | null>(null)
// Get the relation configuration
const relationObject = computed(() => props.field.relationObject || props.field.apiName.replace('Id', ''))
const displayField = computed(() => props.field.relationDisplayField || 'name')
// Display value for the selected record
const displayValue = computed(() => {
if (!selectedRecord.value) return 'Select...'
return selectedRecord.value[displayField.value] || selectedRecord.value.id
})
// Filtered records based on search
const filteredRecords = computed(() => {
if (!searchQuery.value) return records.value
const query = searchQuery.value.toLowerCase()
return records.value.filter(record => {
const displayValue = record[displayField.value] || record.id
return displayValue.toLowerCase().includes(query)
})
})
// Fetch available records for the lookup
const fetchRecords = async () => {
loading.value = true
try {
const response = await api.get(`${props.baseUrl}/${relationObject.value}`)
records.value = response || []
// If we have a modelValue, find the selected record
if (props.modelValue) {
selectedRecord.value = records.value.find(r => r.id === props.modelValue) || null
}
} catch (err) {
console.error('Error fetching lookup records:', err)
} finally {
loading.value = false
}
}
// Handle record selection
const selectRecord = (record: any) => {
selectedRecord.value = record
emit('update:modelValue', record.id)
open.value = false
}
// Clear selection
const clearSelection = () => {
selectedRecord.value = null
emit('update:modelValue', null)
}
// Watch for external modelValue changes
watch(() => props.modelValue, (newValue) => {
if (newValue && records.value.length > 0) {
selectedRecord.value = records.value.find(r => r.id === newValue) || null
} else if (!newValue) {
selectedRecord.value = null
}
})
onMounted(() => {
fetchRecords()
})
</script>
<template>
<div class="lookup-field space-y-2">
<Popover v-model:open="open">
<div class="flex gap-2">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="readonly || loading"
class="flex-1 justify-between"
>
<span class="truncate">{{ displayValue }}</span>
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<Button
v-if="selectedRecord && !readonly"
variant="outline"
size="icon"
@click="clearSelection"
class="shrink-0"
>
<X class="h-4 w-4" />
</Button>
</div>
<PopoverContent class="w-[400px] p-0">
<Command>
<CommandInput
v-model="searchQuery"
placeholder="Search..."
/>
<CommandEmpty>
{{ loading ? 'Loading...' : 'No results found.' }}
</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="record in filteredRecords"
:key="record.id"
:value="record.id"
@select="selectRecord(record)"
>
<Check
:class="cn(
'mr-2 h-4 w-4',
selectedRecord?.id === record.id ? 'opacity-100' : 'opacity-0'
)"
/>
{{ record[displayField] || record.id }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Display readonly value -->
<div v-if="readonly && selectedRecord" class="text-sm text-muted-foreground">
{{ selectedRecord[displayField] || selectedRecord.id }}
</div>
</div>
</template>
<style scoped>
.lookup-field {
width: 100%;
}
</style>

View File

@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { DetailViewConfig, ViewMode, FieldSection } from '@/types/field-types'
import RelatedList from '@/components/RelatedList.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
@@ -13,7 +14,7 @@ import {
} from '@/components/ui/collapsible'
interface Props {
config: DetailViewConfig
config: DetailViewConfig & { relatedLists?: RelatedListConfig[] }
data: any
loading?: boolean
}
@@ -27,6 +28,8 @@ const emit = defineEmits<{
'delete': []
'back': []
'action': [actionId: string]
'navigate': [objectApiName: string, recordId: string]
'createRelated': [objectApiName: string, parentId: string]
}>()
// Organize fields into sections
@@ -47,7 +50,7 @@ const sections = computed<FieldSection[]>(() => {
const getFieldsBySection = (section: FieldSection) => {
return section.fields
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
.filter(Boolean)
.filter((field): field is FieldConfig => field !== undefined)
}
</script>
@@ -121,6 +124,7 @@ const getFieldsBySection = (section: FieldSection) => {
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -139,9 +143,10 @@ const getFieldsBySection = (section: FieldSection) => {
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field?.id"
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -149,6 +154,19 @@ const getFieldsBySection = (section: FieldSection) => {
</template>
</Card>
</div>
<!-- Related Lists -->
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</div>
</template>

View File

@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig } from '@/types/field-types'
import RelatedList from '@/components/RelatedList.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
@@ -29,6 +30,8 @@ const emit = defineEmits<{
'delete': []
'back': []
'action': [actionId: string]
'navigate': [objectApiName: string, recordId: string]
'createRelated': [objectApiName: string, parentId: string]
}>()
const { getDefaultPageLayout } = usePageLayouts()
@@ -165,6 +168,7 @@ const usePageLayout = computed(() => {
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -186,6 +190,7 @@ const usePageLayout = computed(() => {
:key="field?.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -193,6 +198,19 @@ const usePageLayout = computed(() => {
</template>
</Card>
</div>
<!-- Related Lists -->
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</div>
</template>

View File

@@ -205,6 +205,7 @@ const handleAction = (actionId: string) => {
<FieldRenderer
:field="field"
:model-value="row[field.apiName]"
:record-data="row"
:mode="ViewMode.LIST"
/>
</TableCell>

View File

@@ -0,0 +1,399 @@
/**
* 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, RelatedListConfig } 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,
},
],
relatedLists: [
{
title: 'Domains',
relationName: 'domains',
objectApiName: 'domains',
fields: [
{ id: 'domain', apiName: 'domain', label: 'Domain', type: FieldType.TEXT },
{ id: 'isPrimary', apiName: 'isPrimary', label: 'Primary', type: FieldType.BOOLEAN },
{ id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME },
],
canCreate: 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: 'tenants',
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'],
},
],
}

View 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>

View File

@@ -0,0 +1,178 @@
<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)
}
}
}
// Handle navigation to related records
const handleNavigate = (objectApiName: string, recordId: string) => {
router.push(`/central/${objectApiName}/${recordId}/detail`)
}
// Handle creating related records
const handleCreateRelated = (objectApiName: string, parentId: string) => {
// Navigate to create page with parent context
router.push({
path: `/central/${objectApiName}/new`,
query: { tenantId: parentId }
})
}
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"
@navigate="handleNavigate"
@create-related="handleCreateRelated"
/>
<!-- 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>

View 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>

View File

@@ -118,10 +118,20 @@ export interface ListViewConfig extends ViewConfig {
actions?: ViewAction[];
}
export interface RelatedListConfig {
title: string;
relationName: string;
objectApiName: string;
fields: FieldConfig[];
canCreate?: boolean;
createRoute?: string;
}
export interface DetailViewConfig extends ViewConfig {
mode: ViewMode.DETAIL;
sections?: FieldSection[];
actions?: ViewAction[];
relatedLists?: RelatedListConfig[];
}
export interface EditViewConfig extends ViewConfig {