feat: Implement tenant invitation system with onboarding wizard
Backend Implementation: - Add TenantInvitation model with lifecycle management (PENDING/ACCEPTED/EXPIRED/CANCELLED) - Create platform admin API endpoints for invitation CRUD operations - Add public token-based endpoints for invitation retrieval and acceptance - Implement schema_context wrappers to ensure tenant operations run in public schema - Add tenant permissions: can_manage_oauth_credentials, can_accept_payments, can_use_custom_domain, can_white_label, can_api_access - Fix tenant update/create serializers to handle multi-schema environment - Add migrations for tenant permissions and invitation system Frontend Implementation: - Create TenantInviteModal with comprehensive invitation form (350 lines) - Email, business name, subscription tier configuration - Custom user/resource limits - Platform permissions toggles - Future feature flags (video conferencing, event types, calendars, 2FA, logs, data deletion, POS, mobile app) - Build TenantOnboardPage with 4-step wizard for invitation acceptance - Step 1: Account setup (email, password, name) - Step 2: Business details (name, subdomain, contact) - Step 3: Payment setup (conditional based on permissions) - Step 4: Success confirmation with redirect - Extract BusinessCreateModal and BusinessEditModal into separate components - Refactor PlatformBusinesses from 1080 lines to 220 lines (80% reduction) - Add inactive businesses dropdown section (similar to staff page pattern) - Update masquerade button styling to match Users page - Remove deprecated "Add New Tenant" functionality in favor of invitation flow - Add /tenant-onboard route for public access API Integration: - Add platform.ts API functions for tenant invitations - Create React Query hooks in usePlatform.ts for invitation management - Implement proper error handling and success states - Add TypeScript interfaces for invitation types Testing: - Verified end-to-end invitation flow from creation to acceptance - Confirmed tenant, domain, and owner user creation - Validated schema context fixes for multi-tenant environment - Tested active/inactive business filtering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
|
||||
from rest_framework import serializers
|
||||
from core.models import Tenant, Domain
|
||||
from smoothschedule.users.models import User
|
||||
from .models import TenantInvitation
|
||||
|
||||
|
||||
class TenantSerializer(serializers.ModelSerializer):
|
||||
@@ -19,7 +20,9 @@ class TenantSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'subdomain', 'tier', 'is_active',
|
||||
'created_on', 'user_count', 'owner', 'max_users',
|
||||
'max_resources', 'contact_email', 'phone'
|
||||
'max_resources', 'contact_email', 'phone',
|
||||
# Platform permissions
|
||||
'can_manage_oauth_credentials',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -59,6 +62,162 @@ class TenantSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
|
||||
|
||||
class TenantUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating Tenant permissions (platform admins only)"""
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = [
|
||||
'id', 'name', 'is_active', 'subscription_tier',
|
||||
'max_users', 'max_resources',
|
||||
# Platform permissions
|
||||
'can_manage_oauth_credentials',
|
||||
]
|
||||
read_only_fields = ['id']
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update tenant with validated data"""
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
# Must save in public schema
|
||||
with schema_context('public'):
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class TenantCreateSerializer(serializers.Serializer):
|
||||
"""Serializer for creating a new Tenant with domain"""
|
||||
# Required fields
|
||||
name = serializers.CharField(max_length=100)
|
||||
subdomain = serializers.CharField(max_length=63) # Max subdomain length
|
||||
|
||||
# Optional fields with defaults
|
||||
subscription_tier = serializers.ChoiceField(
|
||||
choices=['FREE', 'STARTER', 'PROFESSIONAL', 'ENTERPRISE'],
|
||||
default='FREE'
|
||||
)
|
||||
is_active = serializers.BooleanField(default=True)
|
||||
max_users = serializers.IntegerField(default=5, min_value=1)
|
||||
max_resources = serializers.IntegerField(default=10, min_value=1)
|
||||
contact_email = serializers.EmailField(required=False, allow_blank=True)
|
||||
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||
|
||||
# Platform permissions
|
||||
can_manage_oauth_credentials = serializers.BooleanField(default=False)
|
||||
|
||||
# Owner details (optional - create owner user if provided)
|
||||
owner_email = serializers.EmailField(required=False)
|
||||
owner_name = serializers.CharField(max_length=150, required=False)
|
||||
owner_password = serializers.CharField(max_length=128, required=False, write_only=True)
|
||||
|
||||
def validate_subdomain(self, value):
|
||||
"""Validate subdomain is unique and valid"""
|
||||
import re
|
||||
|
||||
# Check format (lowercase alphanumeric and hyphens, must start with letter)
|
||||
if not re.match(r'^[a-z][a-z0-9-]*$', value.lower()):
|
||||
raise serializers.ValidationError(
|
||||
"Subdomain must start with a letter and contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
|
||||
# Check if subdomain already exists as schema_name
|
||||
if Tenant.objects.filter(schema_name=value.lower()).exists():
|
||||
raise serializers.ValidationError("This subdomain is already taken")
|
||||
|
||||
# Check if domain already exists
|
||||
domain_name = f"{value.lower()}.lvh.me" # TODO: Make base domain configurable
|
||||
if Domain.objects.filter(domain=domain_name).exists():
|
||||
raise serializers.ValidationError("This subdomain is already taken")
|
||||
|
||||
# Reserved subdomains
|
||||
reserved = ['www', 'api', 'admin', 'platform', 'app', 'mail', 'smtp', 'ftp', 'public']
|
||||
if value.lower() in reserved:
|
||||
raise serializers.ValidationError("This subdomain is reserved")
|
||||
|
||||
return value.lower()
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation"""
|
||||
# If owner email is provided, name and password should also be provided
|
||||
owner_email = attrs.get('owner_email')
|
||||
owner_name = attrs.get('owner_name')
|
||||
owner_password = attrs.get('owner_password')
|
||||
|
||||
if owner_email:
|
||||
if not owner_name:
|
||||
raise serializers.ValidationError({
|
||||
'owner_name': 'Owner name is required when creating an owner'
|
||||
})
|
||||
if not owner_password:
|
||||
raise serializers.ValidationError({
|
||||
'owner_password': 'Owner password is required when creating an owner'
|
||||
})
|
||||
# Check if email already exists
|
||||
if User.objects.filter(email=owner_email).exists():
|
||||
raise serializers.ValidationError({
|
||||
'owner_email': 'A user with this email already exists'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create tenant, domain, and optionally owner user"""
|
||||
from django.db import transaction
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
subdomain = validated_data.pop('subdomain')
|
||||
owner_email = validated_data.pop('owner_email', None)
|
||||
owner_name = validated_data.pop('owner_name', None)
|
||||
owner_password = validated_data.pop('owner_password', None)
|
||||
|
||||
# Must create tenant in public schema
|
||||
with schema_context('public'):
|
||||
with transaction.atomic():
|
||||
# Create tenant
|
||||
tenant = Tenant.objects.create(
|
||||
schema_name=subdomain,
|
||||
name=validated_data.get('name'),
|
||||
subscription_tier=validated_data.get('subscription_tier', 'FREE'),
|
||||
is_active=validated_data.get('is_active', True),
|
||||
max_users=validated_data.get('max_users', 5),
|
||||
max_resources=validated_data.get('max_resources', 10),
|
||||
contact_email=validated_data.get('contact_email', ''),
|
||||
phone=validated_data.get('phone', ''),
|
||||
can_manage_oauth_credentials=validated_data.get('can_manage_oauth_credentials', False),
|
||||
)
|
||||
|
||||
# Create primary domain
|
||||
domain_name = f"{subdomain}.lvh.me" # TODO: Make base domain configurable
|
||||
Domain.objects.create(
|
||||
domain=domain_name,
|
||||
tenant=tenant,
|
||||
is_primary=True,
|
||||
is_custom_domain=False,
|
||||
)
|
||||
|
||||
# Create owner user if details provided
|
||||
if owner_email and owner_name and owner_password:
|
||||
# Split name into first/last
|
||||
name_parts = owner_name.split(' ', 1)
|
||||
first_name = name_parts[0]
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else ''
|
||||
|
||||
owner = User.objects.create_user(
|
||||
username=owner_email, # Use email as username
|
||||
email=owner_email,
|
||||
password=owner_password,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
class PlatformUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for User listing (platform view)"""
|
||||
business = serializers.SerializerMethodField()
|
||||
@@ -113,3 +272,109 @@ class PlatformMetricsSerializer(serializers.Serializer):
|
||||
total_users = serializers.IntegerField()
|
||||
mrr = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
growth_rate = serializers.FloatField()
|
||||
|
||||
|
||||
class TenantInvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for TenantInvitation model"""
|
||||
invited_by_email = serializers.ReadOnlyField(source='invited_by.email')
|
||||
created_tenant_name = serializers.ReadOnlyField(source='created_tenant.name')
|
||||
created_user_email = serializers.ReadOnlyField(source='created_user.email')
|
||||
|
||||
class Meta:
|
||||
model = TenantInvitation
|
||||
fields = [
|
||||
'id', 'email', 'token', 'status', 'suggested_business_name',
|
||||
'subscription_tier', 'custom_max_users', 'custom_max_resources',
|
||||
'permissions', 'personal_message', 'invited_by',
|
||||
'invited_by_email', 'created_at', 'expires_at', 'accepted_at',
|
||||
'created_tenant', 'created_tenant_name', 'created_user', 'created_user_email',
|
||||
]
|
||||
read_only_fields = ['id', 'token', 'status', 'created_at', 'expires_at', 'accepted_at',
|
||||
'created_tenant', 'created_tenant_name', 'created_user', 'created_user_email',
|
||||
'invited_by_email']
|
||||
extra_kwargs = {
|
||||
'invited_by': {'write_only': True}, # Only send on creation
|
||||
}
|
||||
|
||||
def validate_permissions(self, value):
|
||||
"""Validate that permissions is a dictionary with boolean values"""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Permissions must be a dictionary.")
|
||||
for key, val in value.items():
|
||||
if not isinstance(val, bool):
|
||||
raise serializers.ValidationError(f"Permission '{key}' must be a boolean.")
|
||||
return value
|
||||
|
||||
|
||||
class TenantInvitationCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating TenantInvitations - sets invited_by automatically"""
|
||||
class Meta:
|
||||
model = TenantInvitation
|
||||
fields = [
|
||||
'email', 'suggested_business_name', 'subscription_tier',
|
||||
'custom_max_users', 'custom_max_resources', 'permissions',
|
||||
'personal_message',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['invited_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class TenantInvitationAcceptSerializer(serializers.Serializer):
|
||||
"""Serializer for accepting a TenantInvitation"""
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(max_length=128, write_only=True)
|
||||
first_name = serializers.CharField(max_length=150)
|
||||
last_name = serializers.CharField(max_length=150)
|
||||
business_name = serializers.CharField(max_length=100)
|
||||
subdomain = serializers.CharField(max_length=63)
|
||||
contact_email = serializers.EmailField(required=False, allow_blank=True)
|
||||
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||
|
||||
def validate_subdomain(self, value):
|
||||
"""Validate subdomain is unique and valid"""
|
||||
import re
|
||||
from core.models import Tenant, Domain
|
||||
|
||||
# Check format (lowercase alphanumeric and hyphens, must start with letter)
|
||||
if not re.match(r'^[a-z][a-z0-9-]*$', value.lower()):
|
||||
raise serializers.ValidationError(
|
||||
"Subdomain must start with a letter and contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
|
||||
# Check if subdomain already exists as schema_name
|
||||
if Tenant.objects.filter(schema_name=value.lower()).exists():
|
||||
raise serializers.ValidationError("This subdomain is already taken")
|
||||
|
||||
# Check if domain already exists
|
||||
domain_name = f"{value.lower()}.lvh.me" # TODO: Make base domain configurable
|
||||
if Domain.objects.filter(domain=domain_name).exists():
|
||||
raise serializers.ValidationError("This subdomain is already taken")
|
||||
|
||||
# Reserved subdomains
|
||||
reserved = ['www', 'api', 'admin', 'platform', 'app', 'mail', 'smtp', 'ftp', 'public']
|
||||
if value.lower() in reserved:
|
||||
raise serializers.ValidationError("This subdomain is reserved")
|
||||
|
||||
return value.lower()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email is unique for owner user"""
|
||||
if User.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return value
|
||||
|
||||
|
||||
class TenantInvitationDetailSerializer(TenantInvitationSerializer):
|
||||
"""Serializer to display invitation details without requiring authentication"""
|
||||
class Meta(TenantInvitationSerializer.Meta):
|
||||
read_only_fields = ['id', 'email', 'token', 'status', 'suggested_business_name',
|
||||
'subscription_tier', 'custom_max_users', 'custom_max_resources',
|
||||
'permissions', 'personal_message', 'created_at', 'expires_at',
|
||||
'accepted_at', 'created_tenant', 'created_user', 'invited_by_email',
|
||||
'created_tenant_name', 'created_user_email']
|
||||
extra_kwargs = {
|
||||
'invited_by': {'read_only': True},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user