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:
poduck
2025-11-28 03:55:07 -05:00
parent 83815fcb34
commit d158c1ddb0
32 changed files with 3715 additions and 201 deletions

View File

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