WIP - manage tenant users from central

This commit is contained in:
Francisco Gaona
2025-12-24 12:17:22 +01:00
parent b9fa3bd008
commit 52c0849de2
7 changed files with 773 additions and 60 deletions

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

@@ -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')

View File

@@ -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;
}

View File

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

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

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

View File

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