WIP - manage tenant users from central
This commit is contained in:
417
TENANT_USER_MANAGEMENT.md
Normal file
417
TENANT_USER_MANAGEMENT.md
Normal 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
|
||||
@@ -112,6 +112,91 @@ export class CentralAdminController {
|
||||
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')
|
||||
|
||||
@@ -8,83 +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> {
|
||||
/**
|
||||
* 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(tenantIdOrSlug)) {
|
||||
// For domain-based lookups, validate the domain still exists before returning cached connection
|
||||
if (this.tenantConnections.has(cacheKey)) {
|
||||
// Validate the domain still exists before returning cached connection
|
||||
const centralPrisma = getCentralPrisma();
|
||||
|
||||
// Check if this looks like a domain (not a UUID)
|
||||
const isDomain = !tenantIdOrSlug.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
|
||||
if (isDomain) {
|
||||
try {
|
||||
const domainRecord = await centralPrisma.domain.findUnique({
|
||||
where: { domain: tenantIdOrSlug },
|
||||
});
|
||||
|
||||
// If domain no longer exists, remove cached connection and continue to error
|
||||
if (!domainRecord) {
|
||||
this.logger.warn(`Domain ${tenantIdOrSlug} no longer exists, removing cached connection`);
|
||||
await this.disconnectTenant(tenantIdOrSlug);
|
||||
throw new Error(`Domain ${tenantIdOrSlug} 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 ${tenantIdOrSlug}:`, error.message);
|
||||
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(tenantIdOrSlug);
|
||||
return this.tenantConnections.get(cacheKey);
|
||||
}
|
||||
|
||||
const centralPrisma = getCentralPrisma();
|
||||
|
||||
let tenant = null;
|
||||
|
||||
// First, try to find by domain (most common case - subdomain lookup)
|
||||
try {
|
||||
const domainRecord = await centralPrisma.domain.findUnique({
|
||||
where: { domain: tenantIdOrSlug },
|
||||
include: { tenant: true },
|
||||
});
|
||||
|
||||
console.log('here:' + JSON.stringify(domainRecord));
|
||||
// Find tenant by domain
|
||||
const domainRecord = await centralPrisma.domain.findUnique({
|
||||
where: { domain },
|
||||
include: { tenant: true },
|
||||
});
|
||||
|
||||
if (domainRecord) {
|
||||
tenant = domainRecord.tenant;
|
||||
this.logger.log(`Found tenant by domain: ${tenantIdOrSlug} -> ${tenant.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(`No domain found for: ${tenantIdOrSlug}, trying ID/slug lookup`);
|
||||
}
|
||||
|
||||
// Fallback: Try to find tenant by ID
|
||||
if (!tenant) {
|
||||
tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantIdOrSlug },
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: Try to find by slug
|
||||
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);
|
||||
|
||||
@@ -115,7 +148,6 @@ export class TenantDatabaseService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.tenantConnections.set(tenantIdOrSlug, tenantKnex);
|
||||
return tenantKnex;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ const fetchRelatedRecords = async () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await api.get(`${props.baseUrl}/${props.config.objectApiName}`, {
|
||||
// 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,
|
||||
},
|
||||
|
||||
136
frontend/components/TenantUserDialog.vue
Normal file
136
frontend/components/TenantUserDialog.vue
Normal 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>
|
||||
@@ -167,6 +167,18 @@ export const tenantDetailConfig: DetailViewConfig = {
|
||||
],
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
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) {
|
||||
@@ -88,6 +93,13 @@ const handleNavigate = (objectApiName: string, recordId: string) => {
|
||||
|
||||
// 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`,
|
||||
@@ -95,6 +107,14 @@ const handleCreateRelated = (objectApiName: string, parentId: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -167,6 +187,14 @@ onMounted(async () => {
|
||||
@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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user