Compare commits
5 Commits
838a010fb2
...
2bc672e4c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc672e4c5 | ||
|
|
962c84e6d2 | ||
|
|
fc1bec4de7 | ||
|
|
0275b96014 | ||
|
|
e4f3bad971 |
3
.env.api
3
.env.api
@@ -8,3 +8,6 @@ REDIS_URL="redis://redis:6379"
|
|||||||
# JWT, multi-tenant hints, etc.
|
# JWT, multi-tenant hints, etc.
|
||||||
JWT_SECRET="devsecret"
|
JWT_SECRET="devsecret"
|
||||||
TENANCY_STRATEGY="single-db"
|
TENANCY_STRATEGY="single-db"
|
||||||
|
|
||||||
|
|
||||||
|
CENTRAL_SUBDOMAINS="central,admin"
|
||||||
|
|||||||
231
CENTRAL_ADMIN_AUTH_GUIDE.md
Normal file
231
CENTRAL_ADMIN_AUTH_GUIDE.md
Normal 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
130
CENTRAL_LOGIN.md
Normal 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
|
||||||
219
RELATED_LISTS_IMPLEMENTATION.md
Normal file
219
RELATED_LISTS_IMPLEMENTATION.md
Normal 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
|
||||||
@@ -18,3 +18,6 @@ JWT_EXPIRES_IN="7d"
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV="development"
|
NODE_ENV="development"
|
||||||
PORT="3000"
|
PORT="3000"
|
||||||
|
|
||||||
|
# Central Admin Subdomains (comma-separated list of subdomains that access the central database)
|
||||||
|
CENTRAL_SUBDOMAINS="central,admin"
|
||||||
|
|||||||
@@ -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
|
### 1. Create a New Migration
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -40,17 +41,36 @@ class RegisterDto {
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
|
async login(
|
||||||
if (!tenantId) {
|
@TenantId() tenantId: string,
|
||||||
throw new UnauthorizedException('Tenant ID is required');
|
@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(
|
const user = await this.authService.validateUser(
|
||||||
tenantId,
|
tenantId,
|
||||||
loginDto.email,
|
loginDto.email,
|
||||||
loginDto.password,
|
loginDto.password,
|
||||||
|
subdomain,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -64,9 +84,15 @@ export class AuthController {
|
|||||||
async register(
|
async register(
|
||||||
@TenantId() tenantId: string,
|
@TenantId() tenantId: string,
|
||||||
@Body() registerDto: RegisterDto,
|
@Body() registerDto: RegisterDto,
|
||||||
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
if (!tenantId) {
|
const subdomain = req.raw?.subdomain;
|
||||||
throw new UnauthorizedException('Tenant ID is required');
|
|
||||||
|
// 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(
|
const user = await this.authService.register(
|
||||||
@@ -75,6 +101,7 @@ export class AuthController {
|
|||||||
registerDto.password,
|
registerDto.password,
|
||||||
registerDto.firstName,
|
registerDto.firstName,
|
||||||
registerDto.lastName,
|
registerDto.lastName,
|
||||||
|
subdomain,
|
||||||
);
|
);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -10,11 +11,24 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private isCentralSubdomain(subdomain: string): boolean {
|
||||||
|
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
|
||||||
|
return centralSubdomains.includes(subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
async validateUser(
|
async validateUser(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
subdomain?: string,
|
||||||
): Promise<any> {
|
): 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 tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const user = await tenantDb('users')
|
const user = await tenantDb('users')
|
||||||
@@ -43,6 +57,31 @@ export class AuthService {
|
|||||||
return null;
|
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) {
|
async login(user: any) {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
@@ -66,7 +105,14 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
firstName?: string,
|
firstName?: string,
|
||||||
lastName?: 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 tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
@@ -88,4 +134,28 @@ export class AuthService {
|
|||||||
const { password: _, ...result } = user;
|
const { password: _, ...result } = user;
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
270
backend/src/tenant/central-admin.controller.ts
Normal file
270
backend/src/tenant/central-admin.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -17,9 +17,14 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
// Extract subdomain from hostname
|
// Extract subdomain from hostname
|
||||||
const host = req.headers.host || '';
|
const host = req.headers.host || '';
|
||||||
const hostname = host.split(':')[0]; // Remove port if present
|
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
|
// For local development, accept x-tenant-id header
|
||||||
let tenantId = req.headers['x-tenant-id'] as string;
|
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}`);
|
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
|
// Try to extract subdomain from Origin header first (for API calls from frontend)
|
||||||
if (tenantId) {
|
if (origin) {
|
||||||
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
|
try {
|
||||||
(req as any).tenantId = tenantId;
|
const originUrl = new URL(origin);
|
||||||
next();
|
const originHost = originUrl.hostname;
|
||||||
return;
|
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")
|
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
|
||||||
@@ -51,6 +70,36 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
this.logger.log(`Extracted subdomain: ${subdomain}`);
|
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
|
// Get tenant by subdomain if available
|
||||||
if (subdomain) {
|
if (subdomain) {
|
||||||
try {
|
try {
|
||||||
@@ -72,9 +121,6 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
// Attach tenant info to request object
|
// Attach tenant info to request object
|
||||||
(req as any).tenantId = tenantId;
|
(req as any).tenantId = tenantId;
|
||||||
if (subdomain) {
|
|
||||||
(req as any).subdomain = subdomain;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { TenantMiddleware } from './tenant.middleware';
|
|||||||
import { TenantDatabaseService } from './tenant-database.service';
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||||
|
import { CentralAdminController } from './central-admin.controller';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [TenantProvisioningController],
|
controllers: [TenantProvisioningController, CentralAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
TenantDatabaseService,
|
TenantDatabaseService,
|
||||||
TenantProvisioningService,
|
TenantProvisioningService,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut } from 'lucide-vue-next'
|
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
@@ -26,12 +26,31 @@ const handleLogout = async () => {
|
|||||||
await logout()
|
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
|
// Fetch objects and group by app
|
||||||
const apps = ref<any[]>([])
|
const apps = ref<any[]>([])
|
||||||
const topLevelObjects = ref<any[]>([])
|
const topLevelObjects = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 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 {
|
try {
|
||||||
const response = await api.get('/setup/objects')
|
const response = await api.get('/setup/objects')
|
||||||
const allObjects = response.data || response || []
|
const allObjects = response.data || response || []
|
||||||
@@ -89,6 +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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -160,6 +212,53 @@ const staticMenuItems = [
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<!-- Central Admin Menu Items (only visible to central admins) -->
|
||||||
|
<SidebarGroup v-if="isCentralAdmin">
|
||||||
|
<SidebarGroupLabel>Central Administration</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<template v-for="item in centralAdminMenuItems" :key="item.title">
|
||||||
|
<!-- Simple menu item -->
|
||||||
|
<SidebarMenuItem v-if="!item.items">
|
||||||
|
<SidebarMenuButton as-child>
|
||||||
|
<NuxtLink :to="item.url">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
|
||||||
|
<!-- Collapsible menu item with submenu -->
|
||||||
|
<Collapsible v-else-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) -->
|
<!-- Top-level Objects (no app) -->
|
||||||
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
|
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
|
||||||
<SidebarGroupLabel>Objects</SidebarGroupLabel>
|
<SidebarGroupLabel>Objects</SidebarGroupLabel>
|
||||||
|
|||||||
186
frontend/components/RelatedList.vue
Normal file
186
frontend/components/RelatedList.vue
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
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 { Badge } from '@/components/ui/badge'
|
||||||
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
|
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import LookupField from '@/components/fields/LookupField.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: FieldConfig
|
field: FieldConfig
|
||||||
modelValue: any
|
modelValue: any
|
||||||
mode: ViewMode
|
mode: ViewMode
|
||||||
readonly?: boolean
|
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<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: any]
|
'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({
|
const value = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (val) => emit('update:modelValue', val),
|
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 isListMode = computed(() => props.mode === ViewMode.LIST)
|
||||||
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
|
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 => {
|
const formatValue = (val: any): string => {
|
||||||
if (val === null || val === undefined) return '-'
|
if (val === null || val === undefined) return '-'
|
||||||
|
|
||||||
switch (props.field.type) {
|
switch (props.field.type) {
|
||||||
|
case FieldType.BELONGS_TO:
|
||||||
|
return relationshipDisplayValue.value
|
||||||
case FieldType.DATE:
|
case FieldType.DATE:
|
||||||
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
|
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
|
||||||
case FieldType.DATETIME:
|
case FieldType.DATETIME:
|
||||||
@@ -113,9 +203,17 @@ const formatValue = (val: any): string => {
|
|||||||
|
|
||||||
<!-- Edit View - Input components -->
|
<!-- Edit View - Input components -->
|
||||||
<div v-else-if="isEditMode && !isReadOnly">
|
<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 -->
|
<!-- Text Input -->
|
||||||
<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"
|
:id="field.id"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"
|
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"
|
||||||
|
|||||||
170
frontend/components/fields/LookupField.vue
Normal file
170
frontend/components/fields/LookupField.vue
Normal 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>
|
||||||
@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
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 { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: DetailViewConfig
|
config: DetailViewConfig & { relatedLists?: RelatedListConfig[] }
|
||||||
data: any
|
data: any
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,8 @@ const emit = defineEmits<{
|
|||||||
'delete': []
|
'delete': []
|
||||||
'back': []
|
'back': []
|
||||||
'action': [actionId: string]
|
'action': [actionId: string]
|
||||||
|
'navigate': [objectApiName: string, recordId: string]
|
||||||
|
'createRelated': [objectApiName: string, parentId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Organize fields into sections
|
// Organize fields into sections
|
||||||
@@ -47,7 +50,7 @@ const sections = computed<FieldSection[]>(() => {
|
|||||||
const getFieldsBySection = (section: FieldSection) => {
|
const getFieldsBySection = (section: FieldSection) => {
|
||||||
return section.fields
|
return section.fields
|
||||||
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
||||||
.filter(Boolean)
|
.filter((field): field is FieldConfig => field !== undefined)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ const getFieldsBySection = (section: FieldSection) => {
|
|||||||
:key="field.id"
|
:key="field.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,9 +143,10 @@ const getFieldsBySection = (section: FieldSection) => {
|
|||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
v-for="field in getFieldsBySection(section)"
|
v-for="field in getFieldsBySection(section)"
|
||||||
:key="field?.id"
|
:key="field.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +154,19 @@ const getFieldsBySection = (section: FieldSection) => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||||
import PageLayoutRenderer from '@/components/PageLayoutRenderer.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 { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -29,6 +30,8 @@ const emit = defineEmits<{
|
|||||||
'delete': []
|
'delete': []
|
||||||
'back': []
|
'back': []
|
||||||
'action': [actionId: string]
|
'action': [actionId: string]
|
||||||
|
'navigate': [objectApiName: string, recordId: string]
|
||||||
|
'createRelated': [objectApiName: string, parentId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { getDefaultPageLayout } = usePageLayouts()
|
const { getDefaultPageLayout } = usePageLayouts()
|
||||||
@@ -165,6 +168,7 @@ const usePageLayout = computed(() => {
|
|||||||
:key="field.id"
|
:key="field.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,6 +190,7 @@ const usePageLayout = computed(() => {
|
|||||||
:key="field?.id"
|
:key="field?.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +198,19 @@ const usePageLayout = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ const handleAction = (actionId: string) => {
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="row[field.apiName]"
|
:model-value="row[field.apiName]"
|
||||||
|
:record-data="row"
|
||||||
:mode="ViewMode.LIST"
|
:mode="ViewMode.LIST"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
399
frontend/composables/useCentralEntities.ts
Normal file
399
frontend/composables/useCentralEntities.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
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>
|
||||||
178
frontend/pages/central/tenants/[[recordId]]/[[view]].vue
Normal file
178
frontend/pages/central/tenants/[[recordId]]/[[view]].vue
Normal 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>
|
||||||
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>
|
||||||
@@ -118,10 +118,20 @@ export interface ListViewConfig extends ViewConfig {
|
|||||||
actions?: ViewAction[];
|
actions?: ViewAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelatedListConfig {
|
||||||
|
title: string;
|
||||||
|
relationName: string;
|
||||||
|
objectApiName: string;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
canCreate?: boolean;
|
||||||
|
createRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DetailViewConfig extends ViewConfig {
|
export interface DetailViewConfig extends ViewConfig {
|
||||||
mode: ViewMode.DETAIL;
|
mode: ViewMode.DETAIL;
|
||||||
sections?: FieldSection[];
|
sections?: FieldSection[];
|
||||||
actions?: ViewAction[];
|
actions?: ViewAction[];
|
||||||
|
relatedLists?: RelatedListConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditViewConfig extends ViewConfig {
|
export interface EditViewConfig extends ViewConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user