feat: Add SMTP settings and collapsible email configuration UI

- Add SMTP fields to TicketEmailSettings model (host, port, TLS/SSL, credentials, from email/name)
- Update serializers with SMTP fields and is_smtp_configured flag
- Add TicketEmailTestSmtpView for testing SMTP connections
- Update frontend API types and hooks for SMTP settings
- Add collapsible IMAP and SMTP configuration sections with "Configured" badges
- Fix TypeScript errors in mockData.ts (missing required fields, type mismatches)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-29 18:28:29 -05:00
parent 0c7d76e264
commit cfc1b36ada
94 changed files with 13419 additions and 1121 deletions

View File

@@ -5,7 +5,161 @@ 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
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
class PlatformSettingsSerializer(serializers.Serializer):
"""Serializer for platform settings (read-only, with masked keys)"""
stripe_secret_key_masked = serializers.SerializerMethodField()
stripe_publishable_key_masked = serializers.SerializerMethodField()
stripe_webhook_secret_masked = serializers.SerializerMethodField()
stripe_account_id = serializers.CharField(read_only=True)
stripe_account_name = serializers.CharField(read_only=True)
stripe_keys_validated_at = serializers.DateTimeField(read_only=True)
stripe_validation_error = serializers.CharField(read_only=True)
has_stripe_keys = serializers.SerializerMethodField()
stripe_keys_from_env = serializers.SerializerMethodField()
updated_at = serializers.DateTimeField(read_only=True)
def get_stripe_secret_key_masked(self, obj):
return obj.mask_key(obj.get_stripe_secret_key())
def get_stripe_publishable_key_masked(self, obj):
return obj.mask_key(obj.get_stripe_publishable_key())
def get_stripe_webhook_secret_masked(self, obj):
return obj.mask_key(obj.get_stripe_webhook_secret())
def get_has_stripe_keys(self, obj):
return obj.has_stripe_keys()
def get_stripe_keys_from_env(self, obj):
return obj.stripe_keys_from_env()
class StripeKeysUpdateSerializer(serializers.Serializer):
"""Serializer for updating Stripe keys"""
stripe_secret_key = serializers.CharField(required=False, allow_blank=True)
stripe_publishable_key = serializers.CharField(required=False, allow_blank=True)
stripe_webhook_secret = serializers.CharField(required=False, allow_blank=True)
class OAuthSettingsSerializer(serializers.Serializer):
"""Serializer for OAuth settings"""
oauth_allow_registration = serializers.BooleanField(required=False, default=True)
# Google
oauth_google_enabled = serializers.BooleanField(required=False, default=False)
oauth_google_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_google_client_secret = serializers.CharField(required=False, allow_blank=True)
# Apple
oauth_apple_enabled = serializers.BooleanField(required=False, default=False)
oauth_apple_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_apple_client_secret = serializers.CharField(required=False, allow_blank=True)
oauth_apple_team_id = serializers.CharField(required=False, allow_blank=True)
oauth_apple_key_id = serializers.CharField(required=False, allow_blank=True)
# Facebook
oauth_facebook_enabled = serializers.BooleanField(required=False, default=False)
oauth_facebook_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_facebook_client_secret = serializers.CharField(required=False, allow_blank=True)
# LinkedIn
oauth_linkedin_enabled = serializers.BooleanField(required=False, default=False)
oauth_linkedin_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_linkedin_client_secret = serializers.CharField(required=False, allow_blank=True)
# Microsoft
oauth_microsoft_enabled = serializers.BooleanField(required=False, default=False)
oauth_microsoft_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_microsoft_client_secret = serializers.CharField(required=False, allow_blank=True)
oauth_microsoft_tenant_id = serializers.CharField(required=False, allow_blank=True)
# Twitter
oauth_twitter_enabled = serializers.BooleanField(required=False, default=False)
oauth_twitter_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_twitter_client_secret = serializers.CharField(required=False, allow_blank=True)
# Twitch
oauth_twitch_enabled = serializers.BooleanField(required=False, default=False)
oauth_twitch_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_twitch_client_secret = serializers.CharField(required=False, allow_blank=True)
class OAuthSettingsResponseSerializer(serializers.Serializer):
"""Response serializer for OAuth settings (with masked secrets)"""
oauth_allow_registration = serializers.BooleanField()
google = serializers.DictField()
apple = serializers.DictField()
facebook = serializers.DictField()
linkedin = serializers.DictField()
microsoft = serializers.DictField()
twitter = serializers.DictField()
twitch = serializers.DictField()
class SubscriptionPlanSerializer(serializers.ModelSerializer):
"""Serializer for subscription plans"""
class Meta:
model = SubscriptionPlan
fields = [
'id', 'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
'is_active', 'is_public', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating subscription plans with optional Stripe integration"""
create_stripe_product = serializers.BooleanField(default=False, write_only=True)
class Meta:
model = SubscriptionPlan
fields = [
'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
'is_active', 'is_public', 'create_stripe_product'
]
def create(self, validated_data):
create_stripe = validated_data.pop('create_stripe_product', False)
if create_stripe and validated_data.get('price_monthly'):
# Create Stripe product and price
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
try:
# Create product
product = stripe.Product.create(
name=validated_data['name'],
description=validated_data.get('description', ''),
metadata={'plan_type': validated_data.get('plan_type', 'base')}
)
validated_data['stripe_product_id'] = product.id
# Create price
price = stripe.Price.create(
product=product.id,
unit_amount=int(validated_data['price_monthly'] * 100), # Convert to cents
currency='usd',
recurring={'interval': 'month'}
)
validated_data['stripe_price_id'] = price.id
except stripe.error.StripeError as e:
raise serializers.ValidationError({'stripe': str(e)})
return super().create(validated_data)
class TenantSerializer(serializers.ModelSerializer):
@@ -289,7 +443,7 @@ class TenantInvitationSerializer(serializers.ModelSerializer):
fields = [
'id', 'email', 'token', 'status', 'suggested_business_name',
'subscription_tier', 'custom_max_users', 'custom_max_resources',
'permissions', 'personal_message', 'invited_by',
'permissions', 'limits', 'personal_message', 'invited_by',
'invited_by_email', 'created_at', 'expires_at', 'accepted_at',
'created_tenant', 'created_tenant_name', 'created_user', 'created_user_email',
]
@@ -309,6 +463,27 @@ class TenantInvitationSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(f"Permission '{key}' must be a boolean.")
return value
def validate_limits(self, value):
"""Validate that limits is a dictionary with valid values"""
if not isinstance(value, dict):
raise serializers.ValidationError("Limits must be a dictionary.")
# Validate each key's value type
boolean_keys = [
'can_add_video_conferencing', 'can_connect_to_api', 'can_book_repeated_events',
'can_require_2fa', 'can_download_logs', 'can_delete_data',
'can_use_masked_phone_numbers', 'can_use_pos', 'can_use_mobile_app'
]
integer_keys = ['max_event_types', 'max_calendars_connected']
for key, val in value.items():
if key in boolean_keys and not isinstance(val, bool):
raise serializers.ValidationError(f"Limit '{key}' must be a boolean.")
if key in integer_keys and val is not None and not isinstance(val, int):
raise serializers.ValidationError(f"Limit '{key}' must be an integer or null.")
return value
class TenantInvitationCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating TenantInvitations - sets invited_by automatically"""
@@ -316,10 +491,31 @@ class TenantInvitationCreateSerializer(serializers.ModelSerializer):
model = TenantInvitation
fields = [
'email', 'suggested_business_name', 'subscription_tier',
'custom_max_users', 'custom_max_resources', 'permissions',
'custom_max_users', 'custom_max_resources', 'permissions', 'limits',
'personal_message',
]
def validate_limits(self, value):
"""Validate that limits is a dictionary with valid values"""
if not isinstance(value, dict):
raise serializers.ValidationError("Limits must be a dictionary.")
# Validate each key's value type
boolean_keys = [
'can_add_video_conferencing', 'can_connect_to_api', 'can_book_repeated_events',
'can_require_2fa', 'can_download_logs', 'can_delete_data',
'can_use_masked_phone_numbers', 'can_use_pos', 'can_use_mobile_app'
]
integer_keys = ['max_event_types', 'max_calendars_connected']
for key, val in value.items():
if key in boolean_keys and not isinstance(val, bool):
raise serializers.ValidationError(f"Limit '{key}' must be a boolean.")
if key in integer_keys and val is not None and not isinstance(val, int):
raise serializers.ValidationError(f"Limit '{key}' must be an integer or null.")
return value
def create(self, validated_data):
validated_data['invited_by'] = self.context['request'].user
return super().create(validated_data)