7 Commits

Author SHA1 Message Date
Francisco Gaona
52c0849de2 WIP - manage tenant users from central 2025-12-24 12:17:22 +01:00
Francisco Gaona
b9fa3bd008 WIP - improve login to tenants by domains 2025-12-24 11:42:44 +01:00
Francisco Gaona
2bc672e4c5 WIP - some fixes 2025-12-24 10:54:19 +01:00
Francisco Gaona
962c84e6d2 WIP - fix lookup field 2025-12-24 00:05:15 +01:00
Francisco Gaona
fc1bec4de7 WIP - related lists and look up field 2025-12-23 23:59:04 +01:00
Francisco Gaona
0275b96014 WIP - central operations 2025-12-23 23:38:45 +01:00
Francisco Gaona
e4f3bad971 WIp - fix login into central 2025-12-23 22:16:58 +01:00
28 changed files with 3523 additions and 43 deletions

View File

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

231
CENTRAL_ADMIN_AUTH_GUIDE.md Normal file
View File

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

130
CENTRAL_LOGIN.md Normal file
View File

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

View File

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

417
TENANT_USER_MANAGEMENT.md Normal file
View File

@@ -0,0 +1,417 @@
# Tenant User Management Implementation
## Overview
This document describes the implementation of tenant user management from the central admin interface. Central administrators can now view and create users for any tenant directly from the tenant detail page.
## Features
### 1. View Tenant Users
- Related list on tenant detail page showing all users for that tenant
- Displays: email, firstName, lastName, createdAt
- Fetches data directly from the tenant's database
### 2. Create Tenant Users
- Modal dialog for creating new users in a tenant
- Form fields:
- Email (required)
- Password (required)
- First Name (optional)
- Last Name (optional)
- Passwords are automatically hashed with bcrypt
- Creates user directly in the tenant's database
## Architecture
### Backend Implementation
**File:** `backend/src/tenant/central-admin.controller.ts`
#### Get Tenant Users Endpoint
```typescript
GET /central/tenants/:id/users
```
- Connects to the tenant's database using `TenantDatabaseService`
- Queries the `users` table
- Returns array of user records
#### Create Tenant User Endpoint
```typescript
POST /central/tenants/:id/users
```
- Accepts: `{ email, password, firstName?, lastName? }`
- Hashes password with bcrypt (10 rounds)
- Creates user in tenant database with timestamps
- Returns created user record
**Key Implementation Details:**
- Uses `tenantDbService.getTenantKnex(tenantId)` to get tenant DB connection
- Connection pooling ensures efficient database access
- Password hashing is done server-side for security
### Frontend Implementation
#### Components
**File:** `frontend/components/TenantUserDialog.vue`
- Reusable modal dialog for creating tenant users
- Form validation (email and password required)
- Loading states and error handling
- Emits 'created' event on success for list refresh
**Props:**
- `open: boolean` - Dialog visibility state
- `tenantId: string` - ID of tenant to create user for
- `tenantName?: string` - Display name of tenant
**Events:**
- `update:open` - Sync dialog visibility
- `created` - User successfully created
#### Page Integration
**File:** `frontend/pages/central/tenants/[[recordId]]/[[view]].vue`
**Added State:**
```typescript
const showTenantUserDialog = ref(false)
const tenantUserDialogTenantId = ref('')
```
**Handler:**
```typescript
const handleCreateRelated = (objectApiName: string, parentId: string) => {
if (objectApiName.includes('tenants/:parentId/users')) {
tenantUserDialogTenantId.value = parentId
showTenantUserDialog.value = true
return
}
// ... standard navigation for other related lists
}
```
**Refresh Handler:**
```typescript
const handleTenantUserCreated = async () => {
// Refresh current record to update related lists
if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
}
```
#### Configuration
**File:** `frontend/composables/useCentralEntities.ts`
Added to `tenantDetailConfig.relatedLists`:
```typescript
{
title: 'Tenant Users',
relationName: 'users',
objectApiName: 'tenants/:parentId/users',
fields: [
{ name: 'email', label: 'Email', type: 'TEXT', required: true },
{ name: 'firstName', label: 'First Name', type: 'TEXT' },
{ name: 'lastName', label: 'Last Name', type: 'TEXT' },
{ name: 'createdAt', label: 'Created', type: 'DATE_TIME' }
],
canCreate: true
}
```
**Key Details:**
- `objectApiName: 'tenants/:parentId/users'` - Special format for nested resource
- `:parentId` placeholder is replaced with actual tenant ID at runtime
- `canCreate: true` enables the "New" button in the related list
#### Related List Component
**File:** `frontend/components/RelatedList.vue`
**Dynamic API Path Resolution:**
```typescript
let apiPath = props.config.objectApiName.replace(':parentId', props.parentId)
const response = await api.get(`/${apiPath}`, {
params: { [parentField]: props.parentId }
})
```
This allows the component to handle nested resource paths like `tenants/:parentId/users`.
## User Flow
### Creating a Tenant User
1. Navigate to Central Admin → Tenants
2. Click on a tenant to view details
3. Scroll to "Tenant Users" related list
4. Click "New" button
5. Fill in the form:
- Enter email address
- Set password
- Optionally add first and last name
6. Click "Create User"
7. Dialog closes and related list refreshes with new user
### Viewing Tenant Users
1. Navigate to Central Admin → Tenants
2. Click on a tenant to view details
3. Scroll to "Tenant Users" related list
4. View table with all users for that tenant
5. See email, name, and creation date for each user
## Security Considerations
### Password Handling
- Passwords are sent over HTTPS
- Backend hashes passwords with bcrypt (10 rounds) before storage
- Passwords never stored in plain text
- Hashing is done server-side, not client-side
### Access Control
- Only central admin users can access these endpoints
- Protected by authentication middleware
- Tenant database connections use secure connection pooling
### Database Access
- Central admin connects to tenant databases on-demand
- Connections are cached but validated before use
- No direct SQL injection risk (using Knex query builder)
## Database Schema
### Tenant User Table Structure
```sql
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
firstName VARCHAR(255),
lastName VARCHAR(255),
createdAt DATETIME,
updatedAt DATETIME
-- Additional fields may exist in actual schema
)
```
## API Reference
### Get Tenant Users
**Request:**
```http
GET /api/central/tenants/{tenantId}/users
Authorization: Bearer <jwt-token>
```
**Response:**
```json
[
{
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"createdAt": "2025-01-26T12:00:00Z",
"updatedAt": "2025-01-26T12:00:00Z"
}
]
```
### Create Tenant User
**Request:**
```http
POST /api/central/tenants/{tenantId}/users
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"email": "newuser@example.com",
"password": "SecurePassword123!",
"firstName": "Jane",
"lastName": "Smith"
}
```
**Response:**
```json
{
"id": "uuid",
"email": "newuser@example.com",
"firstName": "Jane",
"lastName": "Smith",
"createdAt": "2025-01-26T12:00:00Z",
"updatedAt": "2025-01-26T12:00:00Z"
}
```
## Testing
### Manual Testing Steps
1. **Setup:**
- Ensure Docker containers are running
- Have at least one tenant created
- Be logged in as central admin
2. **View Users:**
- Navigate to /central/tenants
- Click on a tenant
- Verify "Tenant Users" related list appears
- Verify existing users are displayed
3. **Create User:**
- Click "New" in Tenant Users section
- Verify dialog opens
- Fill in required fields (email, password)
- Click "Create User"
- Verify success message
- Verify dialog closes
- Verify new user appears in list
4. **Error Handling:**
- Try creating user without email
- Try creating user without password
- Try creating user with duplicate email
- Verify appropriate error messages
### Automated Testing (Future)
```typescript
describe('Tenant User Management', () => {
it('should fetch tenant users', async () => {
const response = await api.get('/central/tenants/tenant-id/users')
expect(response).toBeInstanceOf(Array)
})
it('should create tenant user', async () => {
const newUser = {
email: 'test@example.com',
password: 'password123',
firstName: 'Test',
lastName: 'User'
}
const response = await api.post('/central/tenants/tenant-id/users', newUser)
expect(response.email).toBe(newUser.email)
expect(response.password).toBeUndefined() // Should not return password
})
})
```
## Future Enhancements
### Planned Features
1. **Full CRUD Operations:**
- Edit tenant user details
- Delete tenant users
- Update passwords
2. **Role Management:**
- Assign roles to users during creation
- View and edit user roles
- Permission management
3. **User Navigation:**
- Click on user to view details
- Dedicated user detail page
- Activity history
4. **Bulk Operations:**
- Create multiple users via CSV import
- Bulk role assignment
- Bulk user activation/deactivation
5. **Password Management:**
- Password reset functionality
- Force password change on next login
- Password strength indicators
6. **Audit Logging:**
- Track user creation by central admin
- Log user modifications
- Export audit logs
7. **Search and Filter:**
- Search users by email/name
- Filter by role/status
- Advanced filtering options
## Implementation Notes
### Design Decisions
1. **Modal vs Navigation:**
- Chose modal dialog over page navigation
- Reason: Keeps user in context of tenant detail page
- Better UX for quick user creation
2. **Special API Path Format:**
- Used `tenants/:parentId/users` format
- Reason: Indicates nested resource structure
- Clear relationship between tenant and users
3. **Separate Dialog Component:**
- Created reusable TenantUserDialog component
- Reason: Could be reused in other contexts
- Easier to maintain and test
4. **Server-Side Password Hashing:**
- Hash passwords in backend, not frontend
- Reason: Security best practice
- Consistent with authentication flow
### Known Limitations
1. **No Password Validation:**
- Currently no minimum password requirements
- Could add password strength validation
2. **No Email Validation:**
- Basic email format check only
- Could add email verification
3. **No User Status:**
- Users are created as active by default
- No activation/deactivation workflow
4. **No Role Assignment:**
- Users created without specific roles
- Role management to be added
## Related Documentation
- [RELATED_LISTS_IMPLEMENTATION.md](RELATED_LISTS_IMPLEMENTATION.md) - Related lists feature
- [CENTRAL_ADMIN_AUTH_GUIDE.md](CENTRAL_ADMIN_AUTH_GUIDE.md) - Central admin authentication
- [MULTI_TENANT_IMPLEMENTATION.md](MULTI_TENANT_IMPLEMENTATION.md) - Multi-tenancy architecture
- [TENANT_MIGRATION_GUIDE.md](TENANT_MIGRATION_GUIDE.md) - Tenant database setup
## Troubleshooting
### Common Issues
**Issue: "Cannot GET /api/api/central/tenants/:id/users"**
- Cause: Double API prefix
- Solution: Check that baseUrl in useApi doesn't include /api prefix
**Issue: "Dialog doesn't open"**
- Check: showTenantUserDialog state is being set
- Check: Dialog component is imported correctly
- Check: v-model:open binding is correct
**Issue: "User not appearing in list after creation"**
- Check: handleTenantUserCreated is calling fetchRecord
- Check: API returning correct data
- Check: Related list config matches API response fields
**Issue: "Cannot create user - validation error"**
- Ensure email and password are filled
- Check network tab for actual error from backend
- Verify tenant database schema matches expected structure
**Issue: "Password not hashing"**
- Verify bcrypt is installed in backend
- Check backend logs for hashing errors
- Ensure password field is being passed to backend

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { randomUUID } from 'crypto';
/**
* Central database models using Objection.js
* These models work with the central database (not tenant databases)
*/
export class CentralTenant extends Model {
static tableName = 'tenants';
id: string;
name: string;
slug: string;
dbHost: string;
dbPort: number;
dbName: string;
dbUsername: string;
dbPassword: string;
status: string;
createdAt: Date;
updatedAt: Date;
// Relations
domains?: CentralDomain[];
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
// Auto-generate slug from name if not provided
if (!this.slug && this.name) {
this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
static get relationMappings() {
return {
domains: {
relation: Model.HasManyRelation,
modelClass: CentralDomain,
join: {
from: 'tenants.id',
to: 'domains.tenantId',
},
},
};
}
}
export class CentralDomain extends Model {
static tableName = 'domains';
id: string;
domain: string;
tenantId: string;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
// Relations
tenant?: CentralTenant;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
static get relationMappings() {
return {
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: CentralTenant,
join: {
from: 'domains.tenantId',
to: 'tenants.id',
},
},
};
}
}
export class CentralUser extends Model {
static tableName = 'users';
id: string;
email: string;
password: string;
firstName: string | null;
lastName: string | null;
role: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,368 @@
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 { TenantDatabaseService } from './tenant-database.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,
private readonly tenantDbService: TenantDatabaseService,
) {
// 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 };
}
// Get users for a specific tenant
@Get('tenants/:id/users')
async getTenantUsers(@Req() req: any, @Param('id') tenantId: string) {
this.checkCentralAdmin(req);
try {
// Get tenant to verify it exists
const tenant = await CentralTenant.query().findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Tenant not found');
}
// Connect to tenant database using tenant ID directly
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
// Fetch users from tenant database
const users = await tenantKnex('users').select('*');
// Remove password from response
return users.map(({ password, ...user }) => user);
} catch (error) {
console.error('Error fetching tenant users:', error);
throw error;
}
}
// Create a user in a specific tenant
@Post('tenants/:id/users')
async createTenantUser(
@Req() req: any,
@Param('id') tenantId: string,
@Body() data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
},
) {
this.checkCentralAdmin(req);
try {
// Get tenant to verify it exists
const tenant = await CentralTenant.query().findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Tenant not found');
}
// Connect to tenant database using tenant ID directly
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
// Hash password
const hashedPassword = await bcrypt.hash(data.password, 10);
// Generate UUID for the new user
const userId = require('crypto').randomUUID();
// Create user in tenant database
await tenantKnex('users').insert({
id: userId,
email: data.email,
password: hashedPassword,
firstName: data.firstName || null,
lastName: data.lastName || null,
created_at: new Date(),
updated_at: new Date(),
});
// Fetch and return the created user
const user = await tenantKnex('users').where('id', userId).first();
if (!user) {
throw new Error('Failed to create user');
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
} catch (error) {
console.error('Error creating tenant user:', error);
throw error;
}
}
// ==================== 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);
// Get domain info before deleting to invalidate cache
const domain = await CentralDomain.query().findById(id);
// Delete the domain
await CentralDomain.query().deleteById(id);
// Invalidate tenant connection cache for this domain
if (domain) {
this.tenantDbService.removeTenantConnection(domain.domain);
}
return { success: true };
}
// ==================== USERS (Central Admin Users) ====================
@Get('users')
async getUsers(@Req() req: any) {
this.checkCentralAdmin(req);
const users = await CentralUser.query();
// Remove password from response
return users.map(({ password, ...user }) => user);
}
@Get('users/:id')
async getUser(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
const user = await CentralUser.query().findById(id);
if (!user) {
throw new UnauthorizedException('User not found');
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Post('users')
async createUser(
@Req() req: any,
@Body() data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
role?: string;
isActive?: boolean;
},
) {
this.checkCentralAdmin(req);
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await CentralUser.query().insert({
email: data.email,
password: hashedPassword,
firstName: data.firstName || null,
lastName: data.lastName || null,
role: data.role || 'admin',
isActive: data.isActive !== undefined ? data.isActive : true,
});
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Put('users/:id')
async updateUser(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
role?: string;
isActive?: boolean;
},
) {
this.checkCentralAdmin(req);
const updateData: any = { ...data };
// Hash password if provided
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
} else {
// Remove password from update if not provided
delete updateData.password;
}
const user = await CentralUser.query()
.patchAndFetchById(id, updateData);
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Delete('users/:id')
async deleteUser(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
await CentralUser.query().deleteById(id);
return { success: true };
}
}

View File

@@ -0,0 +1,43 @@
import Knex from 'knex';
import { Model } from 'objection';
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
let centralKnex: Knex.Knex | null = null;
/**
* Get or create a Knex instance for the central database
* This is used for Objection models that work with central entities
*/
export function getCentralKnex(): Knex.Knex {
if (!centralKnex) {
const centralDbUrl = process.env.CENTRAL_DATABASE_URL;
if (!centralDbUrl) {
throw new Error('CENTRAL_DATABASE_URL environment variable is not set');
}
centralKnex = Knex({
client: 'mysql2',
connection: centralDbUrl,
pool: {
min: 2,
max: 10,
},
});
// Bind Objection models to this knex instance
Model.knex(centralKnex);
}
return centralKnex;
}
/**
* Initialize central models with the knex instance
*/
export function initCentralModels() {
const knex = getCentralKnex();
CentralTenant.knex(knex);
CentralDomain.knex(knex);
CentralUser.knex(knex);
}

View File

@@ -8,32 +8,116 @@ export class TenantDatabaseService {
private readonly logger = new Logger(TenantDatabaseService.name);
private tenantConnections: Map<string, Knex> = new Map();
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
if (this.tenantConnections.has(tenantIdOrSlug)) {
return this.tenantConnections.get(tenantIdOrSlug);
/**
* Get tenant database connection by domain (for subdomain-based authentication)
* This is used when users log in via tenant subdomains
*/
async getTenantKnexByDomain(domain: string): Promise<Knex> {
const cacheKey = `domain:${domain}`;
// Check if we have a cached connection
if (this.tenantConnections.has(cacheKey)) {
// Validate the domain still exists before returning cached connection
const centralPrisma = getCentralPrisma();
try {
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
});
// If domain no longer exists, remove cached connection
if (!domainRecord) {
this.logger.warn(`Domain ${domain} no longer exists, removing cached connection`);
await this.disconnectTenant(cacheKey);
throw new Error(`Domain ${domain} not found`);
}
} catch (error) {
// If domain doesn't exist, remove from cache and re-throw
if (error.message.includes('not found')) {
throw error;
}
// For other errors, log but continue with cached connection
this.logger.warn(`Error validating domain ${domain}:`, error.message);
}
return this.tenantConnections.get(cacheKey);
}
const centralPrisma = getCentralPrisma();
// Try to find tenant by ID first, then by slug
let tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantIdOrSlug },
// Find tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: true },
});
if (!tenant) {
tenant = await centralPrisma.tenant.findUnique({
where: { slug: tenantIdOrSlug },
});
if (!domainRecord) {
throw new Error(`Domain ${domain} not found`);
}
const tenant = domainRecord.tenant;
this.logger.log(`Found tenant by domain: ${domain} -> ${tenant.name}`);
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenant.name} is not active`);
}
// Create connection and cache it
const tenantKnex = await this.createTenantConnection(tenant);
this.tenantConnections.set(cacheKey, tenantKnex);
return tenantKnex;
}
/**
* Get tenant database connection by tenant ID (for central admin operations)
* This is used when central admin needs to access tenant databases
*/
async getTenantKnexById(tenantId: string): Promise<Knex> {
const cacheKey = `id:${tenantId}`;
// Check if we have a cached connection (no validation needed for ID-based lookups)
if (this.tenantConnections.has(cacheKey)) {
return this.tenantConnections.get(cacheKey);
}
const centralPrisma = getCentralPrisma();
// Find tenant by ID
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new Error(`Tenant ${tenantIdOrSlug} not found`);
throw new Error(`Tenant ${tenantId} not found`);
}
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenantIdOrSlug} is not active`);
throw new Error(`Tenant ${tenant.name} is not active`);
}
this.logger.log(`Connecting to tenant database by ID: ${tenant.name}`);
// Create connection and cache it
const tenantKnex = await this.createTenantConnection(tenant);
this.tenantConnections.set(cacheKey, tenantKnex);
return tenantKnex;
}
/**
* Legacy method - delegates to domain-based lookup
* @deprecated Use getTenantKnexByDomain or getTenantKnexById instead
*/
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
// Assume it's a domain if it contains a dot
return this.getTenantKnexByDomain(tenantIdOrSlug);
}
/**
* Create a new Knex connection to a tenant database
*/
private async createTenantConnection(tenant: any): Promise<Knex> {
// Decrypt password
const decryptedPassword = this.decryptPassword(tenant.dbPassword);
@@ -64,7 +148,6 @@ export class TenantDatabaseService {
throw error;
}
this.tenantConnections.set(tenantIdOrSlug, tenantKnex);
return tenantKnex;
}

View File

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

View File

@@ -3,11 +3,12 @@ import { TenantMiddleware } from './tenant.middleware';
import { TenantDatabaseService } from './tenant-database.service';
import { TenantProvisioningService } from './tenant-provisioning.service';
import { TenantProvisioningController } from './tenant-provisioning.controller';
import { CentralAdminController } from './central-admin.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TenantProvisioningController],
controllers: [TenantProvisioningController, CentralAdminController],
providers: [
TenantDatabaseService,
TenantProvisioningService,

View File

@@ -17,7 +17,7 @@ import {
SidebarRail,
} from '@/components/ui/sidebar'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut } from 'lucide-vue-next'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building } from 'lucide-vue-next'
const { logout } = useAuth()
const { api } = useApi()
@@ -26,12 +26,31 @@ const handleLogout = async () => {
await logout()
}
// Check if user is central admin (by checking if we're on a central subdomain)
// Use ref instead of computed to avoid hydration mismatch
const isCentralAdmin = ref(false)
// Fetch objects and group by app
const apps = ref<any[]>([])
const topLevelObjects = ref<any[]>([])
const loading = ref(true)
onMounted(async () => {
// Set isCentralAdmin first
if (process.client) {
const hostname = window.location.hostname
const parts = hostname.split('.')
const subdomain = parts.length >= 2 ? parts[0] : null
const centralSubdomains = ['central', 'admin']
isCentralAdmin.value = subdomain ? centralSubdomains.includes(subdomain) : false
}
// Don't fetch tenant objects if we're on a central subdomain
if (isCentralAdmin.value) {
loading.value = false
return
}
try {
const response = await api.get('/setup/objects')
const allObjects = response.data || response || []
@@ -89,6 +108,39 @@ const staticMenuItems = [
],
},
]
const centralAdminMenuItems: Array<{
title: string
icon: any
url?: string
items?: Array<{
title: string
url: string
icon: any
}>
}> = [
{
title: 'Central Admin',
icon: Settings,
items: [
{
title: 'Tenants',
url: '/central/tenants',
icon: Building,
},
{
title: 'Domains',
url: '/central/domains',
icon: Globe,
},
{
title: 'Admin Users',
url: '/central/users',
icon: Users,
},
],
},
]
</script>
<template>
@@ -160,6 +212,53 @@ const staticMenuItems = [
</SidebarGroupContent>
</SidebarGroup>
<!-- Central Admin Menu Items (only visible to central admins) -->
<SidebarGroup v-if="isCentralAdmin">
<SidebarGroupLabel>Central Administration</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<template v-for="item in centralAdminMenuItems" :key="item.title">
<!-- Simple menu item -->
<SidebarMenuItem v-if="!item.items">
<SidebarMenuButton as-child>
<NuxtLink :to="item.url">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
<!-- Collapsible menu item with submenu -->
<Collapsible v-else-if="item.items" as-child :default-open="true" class="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<NuxtLink :to="subItem.url">
<component v-if="subItem.icon" :is="subItem.icon" />
<span>{{ subItem.title }}</span>
</NuxtLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</template>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<!-- Top-level Objects (no app) -->
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
<SidebarGroupLabel>Objects</SidebarGroupLabel>

View File

@@ -0,0 +1,189 @@
<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 {
// Replace :parentId placeholder in the API path
let apiPath = props.config.objectApiName.replace(':parentId', props.parentId)
const response = await api.get(`${props.baseUrl}/${apiPath}`, {
params: {
parentId: props.parentId,
},
})
records.value = response || []
} catch (err: any) {
console.error('Error fetching related records:', err)
error.value = err.message || 'Failed to fetch related records'
} finally {
loading.value = false
}
}
const handleCreateNew = () => {
emit('create', props.config.objectApiName, props.parentId)
}
const handleViewRecord = (recordId: string) => {
emit('navigate', props.config.objectApiName, recordId)
}
const formatValue = (value: any, field: FieldConfig): string => {
if (value === null || value === undefined) return '-'
// Handle different field types
if (field.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (field.type === 'datetime') {
return new Date(value).toLocaleString()
}
if (field.type === 'boolean') {
return value ? 'Yes' : 'No'
}
if (field.type === 'select' && field.options) {
const option = field.options.find(opt => opt.value === value)
return option?.label || value
}
return String(value)
}
onMounted(() => {
fetchRelatedRecords()
})
</script>
<template>
<Card class="related-list">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>{{ config.title }}</CardTitle>
<CardDescription v-if="displayRecords.length > 0">
{{ displayRecords.length }} {{ displayRecords.length === 1 ? 'record' : 'records' }}
</CardDescription>
</div>
<Button
v-if="config.canCreate !== false"
size="sm"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
New
</Button>
</div>
</CardHeader>
<CardContent>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-sm text-destructive py-4">
{{ error }}
</div>
<!-- Empty State -->
<div v-else-if="displayRecords.length === 0" class="text-center py-8 text-muted-foreground">
<p class="text-sm">No {{ config.title.toLowerCase() }} yet</p>
<Button
v-if="config.canCreate !== false"
variant="outline"
size="sm"
class="mt-4"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
Create First {{ config.title.slice(0, -1) }}
</Button>
</div>
<!-- Records Table -->
<div v-else class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead v-for="field in config.fields" :key="field.id">
{{ field.label }}
</TableHead>
<TableHead class="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in displayRecords" :key="record.id">
<TableCell v-for="field in config.fields" :key="field.id">
{{ formatValue(record[field.apiName], field) }}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
@click="handleViewRecord(record.id)"
>
<ExternalLink class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</template>
<style scoped>
.related-list {
margin-top: 1.5rem;
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface Props {
open: boolean
tenantId: string
tenantName?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'created': [user: any]
}>()
const { api } = useApi()
const { toast } = useToast()
const formData = ref({
email: '',
password: '',
firstName: '',
lastName: '',
})
const saving = ref(false)
const handleSubmit = async () => {
if (!formData.value.email || !formData.value.password) {
toast.error('Email and password are required')
return
}
saving.value = true
try {
const response = await api.post(`/central/tenants/${props.tenantId}/users`, formData.value)
toast.success('User created successfully')
emit('created', response)
emit('update:open', false)
// Reset form
formData.value = {
email: '',
password: '',
firstName: '',
lastName: '',
}
} catch (error: any) {
console.error('Error creating user:', error)
toast.error(error.message || 'Failed to create user')
} finally {
saving.value = false
}
}
const handleCancel = () => {
emit('update:open', false)
// Reset form
formData.value = {
email: '',
password: '',
firstName: '',
lastName: '',
}
}
</script>
<template>
<Dialog :open="open" @update:open="(val) => emit('update:open', val)">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Tenant User</DialogTitle>
<DialogDescription v-if="tenantName">
Add a new user to {{ tenantName }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="email">Email *</Label>
<Input
id="email"
v-model="formData.email"
type="email"
placeholder="user@example.com"
required
/>
</div>
<div class="grid gap-2">
<Label for="password">Password *</Label>
<Input
id="password"
v-model="formData.password"
type="password"
placeholder="Enter password"
required
/>
</div>
<div class="grid gap-2">
<Label for="firstName">First Name</Label>
<Input
id="firstName"
v-model="formData.firstName"
type="text"
placeholder="John"
/>
</div>
<div class="grid gap-2">
<Label for="lastName">Last Name</Label>
<Input
id="lastName"
v-model="formData.lastName"
type="text"
placeholder="Doe"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel" :disabled="saving">
Cancel
</Button>
<Button @click="handleSubmit" :disabled="saving">
{{ saving ? 'Creating...' : 'Create User' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,411 @@
/**
* 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,
},
{
title: 'Tenant Users',
relationName: 'users',
objectApiName: 'tenants/:parentId/users',
fields: [
{ id: 'email', apiName: 'email', label: 'Email', type: FieldType.EMAIL },
{ id: 'firstName', apiName: 'firstName', label: 'First Name', type: FieldType.TEXT },
{ id: 'lastName', apiName: 'lastName', label: 'Last Name', type: FieldType.TEXT },
{ id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME },
],
canCreate: true,
},
],
}
export const tenantEditConfig: EditViewConfig = {
objectApiName: 'Tenant',
mode: ViewMode.EDIT,
fields: tenantFields,
sections: [
{
title: 'Basic Information',
fields: ['name', 'slug', 'primaryDomain', 'status'],
},
{
title: 'Advanced Options',
description: 'Optional database configuration (leave blank for defaults)',
fields: ['dbHost', 'dbPort'],
collapsible: true,
defaultCollapsed: true,
},
],
}
// ==================== DOMAINS ====================
export const domainFields: FieldConfig[] = [
{
id: 'domain',
apiName: 'domain',
label: 'Domain',
type: FieldType.TEXT,
isRequired: true,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
helpText: 'Subdomain for this tenant (e.g., "acme" for acme.yourapp.com)',
},
{
id: 'tenantId',
apiName: 'tenantId',
label: 'Tenant',
type: FieldType.BELONGS_TO,
isRequired: true,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
relationObject: 'tenants',
relationDisplayField: 'name',
},
{
id: 'isPrimary',
apiName: 'isPrimary',
label: 'Primary Domain',
type: FieldType.BOOLEAN,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
defaultValue: false,
helpText: 'Mark as the primary domain for this tenant',
},
{
id: 'createdAt',
apiName: 'createdAt',
label: 'Created At',
type: FieldType.DATETIME,
showOnList: true,
showOnDetail: true,
showOnEdit: false,
sortable: true,
},
]
export const domainListConfig: ListViewConfig = {
objectApiName: 'Domain',
mode: ViewMode.LIST,
fields: domainFields,
pageSize: 25,
searchable: true,
filterable: true,
exportable: true,
}
export const domainDetailConfig: DetailViewConfig = {
objectApiName: 'Domain',
mode: ViewMode.DETAIL,
fields: domainFields,
sections: [
{
title: 'Domain Information',
fields: ['domain', 'tenantId', 'isPrimary', 'createdAt'],
},
],
}
export const domainEditConfig: EditViewConfig = {
objectApiName: 'Domain',
mode: ViewMode.EDIT,
fields: domainFields,
sections: [
{
title: 'Domain Configuration',
fields: ['domain', 'tenantId', 'isPrimary'],
},
],
}
// ==================== USERS (Central Admin Users) ====================
export const centralUserFields: FieldConfig[] = [
{
id: 'email',
apiName: 'email',
label: 'Email',
type: FieldType.EMAIL,
isRequired: true,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
{
id: 'firstName',
apiName: 'firstName',
label: 'First Name',
type: FieldType.TEXT,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
{
id: 'lastName',
apiName: 'lastName',
label: 'Last Name',
type: FieldType.TEXT,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
{
id: 'password',
apiName: 'password',
label: 'Password',
type: FieldType.TEXT, // Will be treated as password in edit view
isRequired: true,
showOnList: false,
showOnDetail: false,
showOnEdit: true,
helpText: 'Leave blank to keep existing password',
},
{
id: 'role',
apiName: 'role',
label: 'Role',
type: FieldType.SELECT,
isRequired: true,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Super Admin', value: 'superadmin' },
],
defaultValue: 'admin',
},
{
id: 'isActive',
apiName: 'isActive',
label: 'Active',
type: FieldType.BOOLEAN,
showOnList: true,
showOnDetail: true,
showOnEdit: true,
defaultValue: true,
},
{
id: 'createdAt',
apiName: 'createdAt',
label: 'Created At',
type: FieldType.DATETIME,
showOnList: true,
showOnDetail: true,
showOnEdit: false,
sortable: true,
},
]
export const centralUserListConfig: ListViewConfig = {
objectApiName: 'User',
mode: ViewMode.LIST,
fields: centralUserFields,
pageSize: 25,
searchable: true,
filterable: true,
exportable: true,
}
export const centralUserDetailConfig: DetailViewConfig = {
objectApiName: 'User',
mode: ViewMode.DETAIL,
fields: centralUserFields,
sections: [
{
title: 'User Information',
fields: ['email', 'firstName', 'lastName', 'role', 'isActive'],
},
{
title: 'System Information',
fields: ['createdAt'],
collapsible: true,
},
],
}
export const centralUserEditConfig: EditViewConfig = {
objectApiName: 'User',
mode: ViewMode.EDIT,
fields: centralUserFields,
sections: [
{
title: 'User Information',
fields: ['email', 'firstName', 'lastName'],
},
{
title: 'Access & Security',
fields: ['password', 'role', 'isActive'],
},
],
}

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi'
import { useViewState } from '@/composables/useFieldViews'
import {
domainListConfig,
domainDetailConfig,
domainEditConfig,
} from '@/composables/useCentralEntities'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
const route = useRoute()
const router = useRouter()
const { api } = useApi()
const recordId = computed(() => route.params.recordId as string)
const view = computed(() => {
if (route.params.recordId === 'new' && !route.params.view) {
return 'edit'
}
return (route.params.view as 'list' | 'detail' | 'edit') || 'list'
})
// Use view state composable
const {
records,
currentRecord,
loading: dataLoading,
saving,
fetchRecords,
fetchRecord,
deleteRecords,
handleSave,
} = useViewState('/central/domains')
// Navigation handlers
const handleRowClick = (row: any) => {
router.push(`/central/domains/${row.id}/detail`)
}
const handleCreate = () => {
router.push(`/central/domains/new`)
}
const handleEdit = (row?: any) => {
const id = row?.id || recordId.value
router.push(`/central/domains/${id}/edit`)
}
const handleBack = () => {
if (view.value === 'detail') {
router.push('/central/domains')
} else if (view.value === 'edit') {
if (recordId.value && recordId.value !== 'new') {
router.push(`/central/domains/${recordId.value}/detail`)
} else {
router.push('/central/domains')
}
}
}
const handleCancel = () => {
handleBack()
}
const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} domain(s)? This action cannot be undone.`)) {
try {
const ids = rows.map(r => r.id)
await deleteRecords(ids)
if (view.value !== 'list') {
await router.push('/central/domains')
}
} catch (e: any) {
console.error('Failed to delete domains:', e)
}
}
}
const handleSaveRecord = async (data: any) => {
try {
const savedRecord = await handleSave(data)
if (savedRecord?.id) {
router.push(`/central/domains/${savedRecord.id}/detail`)
} else {
router.push('/central/domains')
}
} catch (e: any) {
console.error('Failed to save domain:', e)
}
}
// Initialize
onMounted(async () => {
if (view.value === 'list') {
await fetchRecords()
} else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
})
</script>
<template>
<NuxtLayout name="default">
<div class="object-view-container">
<!-- Page Header -->
<div v-if="view === 'list'" class="mb-6">
<h1 class="text-3xl font-bold">Domains</h1>
<p class="text-muted-foreground mt-2">
Manage tenant domains and subdomain mappings
</p>
</div>
<!-- List View -->
<ListView
v-if="view === 'list'"
:config="domainListConfig"
:data="records"
:loading="dataLoading"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Detail View -->
<DetailView
v-else-if="view === 'detail' && currentRecord"
:config="domainDetailConfig"
:data="currentRecord"
:loading="dataLoading"
@edit="handleEdit"
@delete="() => handleDelete([currentRecord])"
@back="handleBack"
/>
<!-- Edit View -->
<EditView
v-else-if="(view === 'edit' || recordId === 'new')"
:config="domainEditConfig"
:data="currentRecord || {}"
:loading="dataLoading"
:saving="saving"
@save="handleSaveRecord"
@cancel="handleCancel"
@back="handleBack"
/>
</div>
</NuxtLayout>
</template>
<style scoped>
.object-view-container {
min-height: 100vh;
padding: 2rem;
}
</style>

View File

@@ -0,0 +1,206 @@
<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'
import TenantUserDialog from '@/components/TenantUserDialog.vue'
const route = useRoute()
const router = useRouter()
const { api } = useApi()
// Tenant user dialog state
const showTenantUserDialog = ref(false)
const tenantUserDialogTenantId = ref('')
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) => {
// Special handling for tenant users
if (objectApiName.includes('tenants/:parentId/users')) {
tenantUserDialogTenantId.value = parentId
showTenantUserDialog.value = true
return
}
// Navigate to create page with parent context
router.push({
path: `/central/${objectApiName}/new`,
query: { tenantId: parentId }
})
}
// Handle tenant user created
const handleTenantUserCreated = async () => {
// Refresh the current record to update related lists
if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
}
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>
<!-- Tenant User Creation Dialog -->
<TenantUserDialog
v-model:open="showTenantUserDialog"
:tenant-id="tenantUserDialogTenantId"
:tenant-name="(currentRecord as any)?.name"
@created="handleTenantUserCreated"
/>
</NuxtLayout>
</template>
<style scoped>
.object-view-container {
min-height: 100vh;
padding: 2rem;
}
</style>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi'
import { useViewState } from '@/composables/useFieldViews'
import {
centralUserListConfig,
centralUserDetailConfig,
centralUserEditConfig,
} from '@/composables/useCentralEntities'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
const route = useRoute()
const router = useRouter()
const { api } = useApi()
const recordId = computed(() => route.params.recordId as string)
const view = computed(() => {
if (route.params.recordId === 'new' && !route.params.view) {
return 'edit'
}
return (route.params.view as 'list' | 'detail' | 'edit') || 'list'
})
// Use view state composable
const {
records,
currentRecord,
loading: dataLoading,
saving,
fetchRecords,
fetchRecord,
deleteRecords,
handleSave,
} = useViewState('/central/users')
// Navigation handlers
const handleRowClick = (row: any) => {
router.push(`/central/users/${row.id}/detail`)
}
const handleCreate = () => {
router.push(`/central/users/new`)
}
const handleEdit = (row?: any) => {
const id = row?.id || recordId.value
router.push(`/central/users/${id}/edit`)
}
const handleBack = () => {
if (view.value === 'detail') {
router.push('/central/users')
} else if (view.value === 'edit') {
if (recordId.value && recordId.value !== 'new') {
router.push(`/central/users/${recordId.value}/detail`)
} else {
router.push('/central/users')
}
}
}
const handleCancel = () => {
handleBack()
}
const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} user(s)? This action cannot be undone.`)) {
try {
const ids = rows.map(r => r.id)
await deleteRecords(ids)
if (view.value !== 'list') {
await router.push('/central/users')
}
} catch (e: any) {
console.error('Failed to delete users:', e)
}
}
}
const handleSaveRecord = async (data: any) => {
try {
// Remove password if empty (to keep existing password)
if (data.password === '' || data.password === null) {
delete data.password
}
const savedRecord = await handleSave(data)
if (savedRecord?.id) {
router.push(`/central/users/${savedRecord.id}/detail`)
} else {
router.push('/central/users')
}
} catch (e: any) {
console.error('Failed to save user:', e)
}
}
// Initialize
onMounted(async () => {
if (view.value === 'list') {
await fetchRecords()
} else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
})
</script>
<template>
<NuxtLayout name="default">
<div class="object-view-container">
<!-- Page Header -->
<div v-if="view === 'list'" class="mb-6">
<h1 class="text-3xl font-bold">Admin Users</h1>
<p class="text-muted-foreground mt-2">
Manage central administrator accounts
</p>
</div>
<!-- List View -->
<ListView
v-if="view === 'list'"
:config="centralUserListConfig"
:data="records"
:loading="dataLoading"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Detail View -->
<DetailView
v-else-if="view === 'detail' && currentRecord"
:config="centralUserDetailConfig"
:data="currentRecord"
:loading="dataLoading"
@edit="handleEdit"
@delete="() => handleDelete([currentRecord])"
@back="handleBack"
/>
<!-- Edit View -->
<EditView
v-else-if="(view === 'edit' || recordId === 'new')"
:config="centralUserEditConfig"
:data="currentRecord || {}"
:loading="dataLoading"
:saving="saving"
@save="handleSaveRecord"
@cancel="handleCancel"
@back="handleBack"
/>
</div>
</NuxtLayout>
</template>
<style scoped>
.object-view-container {
min-height: 100vh;
padding: 2rem;
}
</style>

View File

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