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:
@@ -82,7 +82,7 @@ LOCAL_APPS = [
|
||||
"core",
|
||||
"schedule",
|
||||
"payments",
|
||||
"platform_admin",
|
||||
"platform_admin.apps.PlatformAdminConfig",
|
||||
# Your stuff: custom apps go here
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
|
||||
@@ -14,7 +14,8 @@ from .base import INSTALLED_APPS, MIDDLEWARE, DATABASES, LOGGING, env
|
||||
SHARED_APPS = [
|
||||
'django_tenants', # Must be first
|
||||
'core', # Core models (Tenant, Domain, PermissionGrant)
|
||||
|
||||
'platform_admin.apps.PlatformAdminConfig', # Platform management (TenantInvitation, etc.)
|
||||
|
||||
# Django built-ins (must be in shared
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
@@ -23,10 +24,10 @@ SHARED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.admin',
|
||||
|
||||
|
||||
# Users app (shared across tenants)
|
||||
'smoothschedule.users',
|
||||
|
||||
|
||||
# Third-party apps that should be shared
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
|
||||
@@ -16,7 +16,10 @@ from smoothschedule.users.api_views import (
|
||||
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
||||
invitation_details_view, accept_invitation_view, decline_invitation_view
|
||||
)
|
||||
from schedule.api_views import current_business_view, update_business_view
|
||||
from schedule.api_views import (
|
||||
current_business_view, update_business_view,
|
||||
oauth_settings_view, oauth_credentials_view
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
@@ -59,6 +62,8 @@ urlpatterns += [
|
||||
# Business API
|
||||
path("api/business/current/", current_business_view, name="current_business"),
|
||||
path("api/business/current/update/", update_business_view, name="update_business"),
|
||||
path("api/business/oauth-settings/", oauth_settings_view, name="oauth_settings"),
|
||||
path("api/business/oauth-credentials/", oauth_credentials_view, name="oauth_credentials"),
|
||||
# API Docs
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||
path(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 07:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_tenant_email_logo_alter_tenant_logo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='oauth_allow_registration',
|
||||
field=models.BooleanField(default=True, help_text='Allow new customers to register via OAuth'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='oauth_auto_link_by_email',
|
||||
field=models.BooleanField(default=True, help_text='Automatically link OAuth accounts to existing accounts with matching email'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='oauth_credentials',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Custom OAuth credentials for each provider (encrypted at rest)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='oauth_enabled_providers',
|
||||
field=models.JSONField(blank=True, default=list, help_text="List of enabled OAuth providers (e.g., ['google', 'facebook', 'apple'])"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='oauth_use_custom_credentials',
|
||||
field=models.BooleanField(default=False, help_text="Use business's own OAuth app credentials instead of platform credentials"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 07:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_add_oauth_settings_to_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_manage_oauth_credentials',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can manage their own OAuth credentials'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_add_can_manage_oauth_credentials'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_accept_payments',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can accept online payments via Stripe Connect'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_api_access',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can access the API for integrations'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_use_custom_domain',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can configure a custom domain'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_white_label',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can remove SmoothSchedule branding'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='initial_setup_complete',
|
||||
field=models.BooleanField(default=False, help_text='Whether the business has completed initial onboarding'),
|
||||
),
|
||||
]
|
||||
@@ -70,7 +70,62 @@ class Tenant(TenantMixin):
|
||||
# Metadata
|
||||
contact_email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
|
||||
# OAuth Settings - which providers are enabled for this business
|
||||
oauth_enabled_providers = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of enabled OAuth providers (e.g., ['google', 'facebook', 'apple'])"
|
||||
)
|
||||
oauth_allow_registration = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Allow new customers to register via OAuth"
|
||||
)
|
||||
oauth_auto_link_by_email = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Automatically link OAuth accounts to existing accounts with matching email"
|
||||
)
|
||||
|
||||
# Custom OAuth Credentials (for businesses that want their own branding)
|
||||
oauth_use_custom_credentials = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Use business's own OAuth app credentials instead of platform credentials"
|
||||
)
|
||||
oauth_credentials = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Custom OAuth credentials for each provider (encrypted at rest)"
|
||||
)
|
||||
|
||||
# Platform-controlled permissions (set by platform admins)
|
||||
# These are special permissions granted via tenant invitation, not included in standard tier packages
|
||||
can_manage_oauth_credentials = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can manage their own OAuth credentials"
|
||||
)
|
||||
can_accept_payments = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can accept online payments via Stripe Connect"
|
||||
)
|
||||
can_use_custom_domain = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can configure a custom domain"
|
||||
)
|
||||
can_white_label = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can remove SmoothSchedule branding"
|
||||
)
|
||||
can_api_access = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can access the API for integrations"
|
||||
)
|
||||
|
||||
# Onboarding tracking
|
||||
initial_setup_complete = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the business has completed initial onboarding"
|
||||
)
|
||||
|
||||
# Auto-created fields from TenantMixin:
|
||||
# - schema_name (unique, indexed)
|
||||
# - auto_create_schema
|
||||
|
||||
43
smoothschedule/platform_admin/migrations/0001_initial.py
Normal file
43
smoothschedule/platform_admin/migrations/0001_initial.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 08:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_add_tenant_permissions'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TenantInvitation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(help_text='Email address to send invitation to', max_length=254)),
|
||||
('token', models.CharField(max_length=64, unique=True)),
|
||||
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
|
||||
('suggested_business_name', models.CharField(blank=True, help_text='Suggested business name (owner can change during onboarding)', max_length=100)),
|
||||
('subscription_tier', models.CharField(choices=[('FREE', 'Free Trial'), ('STARTER', 'Starter'), ('PROFESSIONAL', 'Professional'), ('ENTERPRISE', 'Enterprise')], default='PROFESSIONAL', max_length=50)),
|
||||
('custom_max_users', models.IntegerField(blank=True, help_text='Custom max users limit (null = use tier default)', null=True)),
|
||||
('custom_max_resources', models.IntegerField(blank=True, help_text='Custom max resources limit (null = use tier default)', null=True)),
|
||||
('permissions', models.JSONField(blank=True, default=dict, help_text='Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)')),
|
||||
('personal_message', models.TextField(blank=True, help_text='Optional personal message to include in the invitation email')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_tenant', models.ForeignKey(blank=True, help_text='Tenant created when invitation was accepted', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invitation', to='core.tenant')),
|
||||
('created_user', models.ForeignKey(blank=True, help_text='Owner user created when invitation was accepted', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenant_invitation_accepted', to=settings.AUTH_USER_MODEL)),
|
||||
('invited_by', models.ForeignKey(help_text='Platform admin who sent the invitation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenant_invitations_sent', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['token'], name='platform_ad_token_7ec24c_idx'), models.Index(fields=['email', 'status'], name='platform_ad_email_309f0f_idx'), models.Index(fields=['status', 'expires_at'], name='platform_ad_status_f2fa75_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
217
smoothschedule/platform_admin/models.py
Normal file
217
smoothschedule/platform_admin/models.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Platform Admin Models
|
||||
Models for platform-level operations like tenant invitations
|
||||
"""
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class TenantInvitation(models.Model):
|
||||
"""
|
||||
Invitation for new business owners to create their tenant.
|
||||
Allows platform admins to pre-configure custom limits and permissions.
|
||||
|
||||
Flow:
|
||||
1. Platform admin creates invitation with email and custom settings
|
||||
2. System sends email with unique token link
|
||||
3. Invitee clicks link, completes onboarding wizard
|
||||
4. Tenant, domain, and owner user are created with pre-configured settings
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'PENDING', _('Pending')
|
||||
ACCEPTED = 'ACCEPTED', _('Accepted')
|
||||
EXPIRED = 'EXPIRED', _('Expired')
|
||||
CANCELLED = 'CANCELLED', _('Cancelled')
|
||||
|
||||
# Invitation target
|
||||
email = models.EmailField(help_text="Email address to send invitation to")
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING
|
||||
)
|
||||
|
||||
# Pre-configured business settings (owner can modify during onboarding)
|
||||
suggested_business_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Suggested business name (owner can change during onboarding)"
|
||||
)
|
||||
|
||||
# Subscription settings
|
||||
subscription_tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
],
|
||||
default='PROFESSIONAL'
|
||||
)
|
||||
|
||||
# Custom limits (null = use tier defaults)
|
||||
custom_max_users = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Custom max users limit (null = use tier default)"
|
||||
)
|
||||
custom_max_resources = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Custom max resources limit (null = use tier default)"
|
||||
)
|
||||
|
||||
# Platform permissions (what features this tenant can access)
|
||||
# These are special permissions not available in normal tier packages
|
||||
permissions = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)"
|
||||
)
|
||||
# Example permissions structure:
|
||||
# {
|
||||
# "can_manage_oauth_credentials": true,
|
||||
# "can_accept_payments": true,
|
||||
# "can_use_custom_domain": true,
|
||||
# "can_white_label": false,
|
||||
# "can_api_access": true,
|
||||
# }
|
||||
|
||||
# Personal message to include in email
|
||||
personal_message = models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional personal message to include in the invitation email"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
invited_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='tenant_invitations_sent',
|
||||
help_text="Platform admin who sent the invitation"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
accepted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Links to created resources (after acceptance)
|
||||
created_tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='invitation',
|
||||
help_text="Tenant created when invitation was accepted"
|
||||
)
|
||||
created_user = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tenant_invitation_accepted',
|
||||
help_text="Owner user created when invitation was accepted"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'platform_admin'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['token']),
|
||||
models.Index(fields=['email', 'status']),
|
||||
models.Index(fields=['status', 'expires_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Tenant invitation for {self.email} ({self.get_status_display()})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.token:
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
if not self.expires_at:
|
||||
# Default expiration: 7 days
|
||||
self.expires_at = timezone.now() + timedelta(days=7)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if invitation can still be accepted"""
|
||||
if self.status != self.Status.PENDING:
|
||||
return False
|
||||
if timezone.now() > self.expires_at:
|
||||
return False
|
||||
return True
|
||||
|
||||
def accept(self, tenant, user):
|
||||
"""Mark invitation as accepted and link to created resources"""
|
||||
self.status = self.Status.ACCEPTED
|
||||
self.accepted_at = timezone.now()
|
||||
self.created_tenant = tenant
|
||||
self.created_user = user
|
||||
self.save()
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel a pending invitation"""
|
||||
if self.status == self.Status.PENDING:
|
||||
self.status = self.Status.CANCELLED
|
||||
self.save()
|
||||
|
||||
def get_effective_max_users(self):
|
||||
"""Get max users (custom or tier default)"""
|
||||
if self.custom_max_users is not None:
|
||||
return self.custom_max_users
|
||||
# Tier defaults
|
||||
tier_defaults = {
|
||||
'FREE': 2,
|
||||
'STARTER': 5,
|
||||
'PROFESSIONAL': 15,
|
||||
'ENTERPRISE': 50,
|
||||
}
|
||||
return tier_defaults.get(self.subscription_tier, 5)
|
||||
|
||||
def get_effective_max_resources(self):
|
||||
"""Get max resources (custom or tier default)"""
|
||||
if self.custom_max_resources is not None:
|
||||
return self.custom_max_resources
|
||||
# Tier defaults
|
||||
tier_defaults = {
|
||||
'FREE': 3,
|
||||
'STARTER': 10,
|
||||
'PROFESSIONAL': 30,
|
||||
'ENTERPRISE': 100,
|
||||
}
|
||||
return tier_defaults.get(self.subscription_tier, 10)
|
||||
|
||||
@classmethod
|
||||
def create_invitation(cls, email, invited_by, subscription_tier='PROFESSIONAL',
|
||||
suggested_business_name='', custom_max_users=None,
|
||||
custom_max_resources=None, permissions=None,
|
||||
personal_message=''):
|
||||
"""
|
||||
Create a new tenant invitation, cancelling any existing pending invitations
|
||||
for the same email.
|
||||
"""
|
||||
# Cancel existing pending invitations for this email
|
||||
cls.objects.filter(
|
||||
email=email,
|
||||
status=cls.Status.PENDING
|
||||
).update(status=cls.Status.CANCELLED)
|
||||
|
||||
# Create new invitation
|
||||
return cls.objects.create(
|
||||
email=email,
|
||||
invited_by=invited_by,
|
||||
subscription_tier=subscription_tier,
|
||||
suggested_business_name=suggested_business_name,
|
||||
custom_max_users=custom_max_users,
|
||||
custom_max_resources=custom_max_resources,
|
||||
permissions=permissions or {},
|
||||
personal_message=personal_message,
|
||||
)
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,26 @@ Platform URL Configuration
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import TenantViewSet, PlatformUserViewSet
|
||||
from .views import TenantViewSet, PlatformUserViewSet, TenantInvitationViewSet
|
||||
|
||||
app_name = 'platform'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'businesses', TenantViewSet, basename='business')
|
||||
router.register(r'users', PlatformUserViewSet, basename='user')
|
||||
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
# Public endpoints for tenant invitations
|
||||
path(
|
||||
'tenant-invitations/token/<str:token>/',
|
||||
TenantInvitationViewSet.as_view({'get': 'retrieve_by_token'}),
|
||||
name='tenant-invitation-retrieve-by-token'
|
||||
),
|
||||
path(
|
||||
'tenant-invitations/token/<str:token>/accept/',
|
||||
TenantInvitationViewSet.as_view({'post': 'accept'}),
|
||||
name='tenant-invitation-accept'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,26 +2,44 @@
|
||||
Platform Views
|
||||
API views for platform-level operations
|
||||
"""
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Count
|
||||
from django.db import transaction, connection
|
||||
from django.utils import timezone
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
from core.models import Tenant
|
||||
from core.models import Tenant, Domain
|
||||
from smoothschedule.users.models import User
|
||||
from .serializers import TenantSerializer, PlatformUserSerializer, PlatformMetricsSerializer
|
||||
from .models import TenantInvitation
|
||||
from .serializers import (
|
||||
TenantSerializer,
|
||||
TenantCreateSerializer,
|
||||
TenantUpdateSerializer,
|
||||
PlatformUserSerializer,
|
||||
PlatformMetricsSerializer,
|
||||
TenantInvitationSerializer,
|
||||
TenantInvitationCreateSerializer,
|
||||
TenantInvitationAcceptSerializer,
|
||||
TenantInvitationDetailSerializer
|
||||
)
|
||||
from .permissions import IsPlatformAdmin, IsPlatformUser
|
||||
|
||||
|
||||
class TenantViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class TenantViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing tenants (businesses).
|
||||
ViewSet for viewing, creating, and updating tenants (businesses).
|
||||
Platform admins only.
|
||||
"""
|
||||
queryset = Tenant.objects.all().order_by('-created_on')
|
||||
serializer_class = TenantSerializer
|
||||
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
||||
http_method_names = ['get', 'post', 'patch', 'head', 'options'] # Allow GET, POST, and PATCH
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optionally filter by active status"""
|
||||
@@ -31,6 +49,14 @@ class TenantViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = queryset.filter(is_active=is_active.lower() == 'true')
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use different serializer for different actions"""
|
||||
if self.action == 'create':
|
||||
return TenantCreateSerializer
|
||||
if self.action in ['partial_update', 'update']:
|
||||
return TenantUpdateSerializer
|
||||
return TenantSerializer
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def metrics(self, request):
|
||||
"""Get platform-wide tenant metrics"""
|
||||
@@ -49,6 +75,7 @@ class TenantViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
|
||||
class PlatformUserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing all users across the platform.
|
||||
@@ -75,3 +102,136 @@ class PlatformUserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
# TODO: Filter by business when we add tenant reference to User
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TenantInvitationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Tenant Invitations.
|
||||
Platform admins only for all actions except token-based retrieval and acceptance.
|
||||
"""
|
||||
queryset = TenantInvitation.objects.all().order_by('-created_at')
|
||||
serializer_class = TenantInvitationSerializer
|
||||
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
||||
http_method_names = ['get', 'post', 'delete', 'head', 'options']
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return TenantInvitationCreateSerializer
|
||||
return TenantInvitationSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# The create method on the model will handle cancelling old invitations
|
||||
# and generating token/expires_at.
|
||||
instance = serializer.save(invited_by=self.request.user)
|
||||
# TODO: Send invitation email here (e.g., using Celery task)
|
||||
# Placeholder for email sending:
|
||||
# from .tasks import send_invitation_email
|
||||
# send_invitation_email.delay(instance.id)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def resend(self, request, pk=None):
|
||||
"""Resend invitation email for a specific invitation."""
|
||||
invitation = self.get_object()
|
||||
if not invitation.is_valid():
|
||||
return Response(
|
||||
{"detail": "Invitation is not in a valid state to be resent."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# Update expires_at and token for resend (optional, but good practice)
|
||||
invitation.expires_at = timezone.now() + timedelta(days=7)
|
||||
invitation.token = secrets.token_urlsafe(32) # Generate new token
|
||||
invitation.save()
|
||||
|
||||
# TODO: Send invitation email here (e.g., using Celery task)
|
||||
# Placeholder for email sending:
|
||||
# from .tasks import send_invitation_email
|
||||
# send_invitation_email.delay(invitation.id)
|
||||
return Response({"detail": "Invitation email resent successfully."}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def cancel(self, request, pk=None):
|
||||
"""Cancel a pending invitation."""
|
||||
invitation = self.get_object()
|
||||
if invitation.status == TenantInvitation.Status.PENDING:
|
||||
invitation.cancel()
|
||||
return Response({"detail": "Invitation cancelled successfully."}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{"detail": "Only pending invitations can be cancelled."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Public actions (no authentication required, accessible via token)
|
||||
@action(detail=False, methods=['get'], url_path='token/(?P<token>[^/.]+)', permission_classes=[])
|
||||
def retrieve_by_token(self, request, token=None):
|
||||
"""Retrieve invitation details using a public token."""
|
||||
try:
|
||||
invitation = TenantInvitation.objects.get(token=token)
|
||||
except TenantInvitation.DoesNotExist:
|
||||
return Response({"detail": "Invitation not found or invalid token."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if not invitation.is_valid():
|
||||
return Response({"detail": "Invitation is no longer valid."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = TenantInvitationDetailSerializer(invitation)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='token/(?P<token>[^/.]+)/accept', permission_classes=[])
|
||||
def accept(self, request, token=None):
|
||||
"""Accept an invitation, create tenant and owner user."""
|
||||
try:
|
||||
invitation = TenantInvitation.objects.get(token=token)
|
||||
except TenantInvitation.DoesNotExist:
|
||||
return Response({"detail": "Invitation not found or invalid token."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if not invitation.is_valid():
|
||||
return Response({"detail": "Invitation is no longer valid."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = TenantInvitationAcceptSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Force execution in public schema for tenant creation
|
||||
with schema_context('public'):
|
||||
with transaction.atomic():
|
||||
# Create Tenant
|
||||
subdomain = serializer.validated_data['subdomain'].lower()
|
||||
tenant = Tenant.objects.create(
|
||||
schema_name=subdomain,
|
||||
name=serializer.validated_data['business_name'],
|
||||
subscription_tier=invitation.subscription_tier,
|
||||
max_users=invitation.get_effective_max_users(),
|
||||
max_resources=invitation.get_effective_max_resources(),
|
||||
contact_email=serializer.validated_data.get('contact_email', invitation.email),
|
||||
phone=serializer.validated_data.get('phone', ''),
|
||||
# Set platform permissions from invitation
|
||||
can_manage_oauth_credentials=invitation.permissions.get('can_manage_oauth_credentials', False),
|
||||
can_accept_payments=invitation.permissions.get('can_accept_payments', False),
|
||||
can_use_custom_domain=invitation.permissions.get('can_use_custom_domain', False),
|
||||
can_white_label=invitation.permissions.get('can_white_label', False),
|
||||
can_api_access=invitation.permissions.get('can_api_access', False),
|
||||
initial_setup_complete=True, # Mark as complete after onboarding
|
||||
)
|
||||
|
||||
# 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
|
||||
owner_user = User.objects.create_user(
|
||||
username=serializer.validated_data['email'],
|
||||
email=serializer.validated_data['email'],
|
||||
password=serializer.validated_data['password'],
|
||||
first_name=serializer.validated_data['first_name'],
|
||||
last_name=serializer.validated_data['last_name'],
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# Mark invitation as accepted
|
||||
invitation.accept(tenant, owner_user)
|
||||
|
||||
return Response({"detail": "Invitation accepted, tenant and user created."}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -57,6 +57,8 @@ def current_business_view(request):
|
||||
'initial_setup_complete': False,
|
||||
'website_pages': {},
|
||||
'customer_dashboard_content': [],
|
||||
# Platform permissions
|
||||
'can_manage_oauth_credentials': tenant.can_manage_oauth_credentials,
|
||||
}
|
||||
|
||||
return Response(business_data, status=status.HTTP_200_OK)
|
||||
@@ -165,4 +167,183 @@ def update_business_view(request):
|
||||
'customer_dashboard_content': [],
|
||||
}
|
||||
|
||||
return Response(business_data, status=status.HTTP_200_OK)
|
||||
return Response(business_data)
|
||||
|
||||
|
||||
# Available OAuth providers that the platform supports
|
||||
AVAILABLE_OAUTH_PROVIDERS = [
|
||||
{
|
||||
'id': 'google',
|
||||
'name': 'Google',
|
||||
'icon': 'google',
|
||||
'description': 'Sign in with Google accounts',
|
||||
},
|
||||
{
|
||||
'id': 'facebook',
|
||||
'name': 'Facebook',
|
||||
'icon': 'facebook',
|
||||
'description': 'Sign in with Facebook accounts',
|
||||
},
|
||||
{
|
||||
'id': 'apple',
|
||||
'name': 'Apple',
|
||||
'icon': 'apple',
|
||||
'description': 'Sign in with Apple ID',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@api_view(['GET', 'PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def oauth_settings_view(request):
|
||||
"""
|
||||
Get or update OAuth settings for the current business
|
||||
GET /api/business/oauth-settings/
|
||||
PATCH /api/business/oauth-settings/
|
||||
"""
|
||||
user = request.user
|
||||
tenant = user.tenant
|
||||
|
||||
# Platform users don't have a tenant
|
||||
if not tenant:
|
||||
return Response({'error': 'No business found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response({
|
||||
'settings': {
|
||||
'enabled_providers': tenant.oauth_enabled_providers or [],
|
||||
'allow_registration': tenant.oauth_allow_registration,
|
||||
'auto_link_by_email': tenant.oauth_auto_link_by_email,
|
||||
'use_custom_credentials': tenant.oauth_use_custom_credentials,
|
||||
},
|
||||
'available_providers': AVAILABLE_OAUTH_PROVIDERS,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# PATCH - update settings
|
||||
# Only owners can update OAuth settings
|
||||
if user.role.lower() != 'tenant_owner':
|
||||
return Response({'error': 'Only business owners can update OAuth settings'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
data = request.data
|
||||
|
||||
if 'enabled_providers' in data:
|
||||
# Validate that all providers are valid
|
||||
valid_provider_ids = [p['id'] for p in AVAILABLE_OAUTH_PROVIDERS]
|
||||
enabled = data['enabled_providers']
|
||||
if not isinstance(enabled, list):
|
||||
return Response({'error': 'enabled_providers must be a list'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
for provider in enabled:
|
||||
if provider not in valid_provider_ids:
|
||||
return Response({'error': f'Invalid provider: {provider}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
tenant.oauth_enabled_providers = enabled
|
||||
|
||||
if 'allow_registration' in data:
|
||||
tenant.oauth_allow_registration = bool(data['allow_registration'])
|
||||
|
||||
if 'auto_link_by_email' in data:
|
||||
tenant.oauth_auto_link_by_email = bool(data['auto_link_by_email'])
|
||||
|
||||
if 'use_custom_credentials' in data:
|
||||
tenant.oauth_use_custom_credentials = bool(data['use_custom_credentials'])
|
||||
|
||||
tenant.save()
|
||||
|
||||
return Response({
|
||||
'settings': {
|
||||
'enabled_providers': tenant.oauth_enabled_providers or [],
|
||||
'allow_registration': tenant.oauth_allow_registration,
|
||||
'auto_link_by_email': tenant.oauth_auto_link_by_email,
|
||||
'use_custom_credentials': tenant.oauth_use_custom_credentials,
|
||||
},
|
||||
'available_providers': AVAILABLE_OAUTH_PROVIDERS,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@api_view(['GET', 'PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def oauth_credentials_view(request):
|
||||
"""
|
||||
Get or update custom OAuth credentials for the current business
|
||||
GET /api/business/oauth-credentials/
|
||||
PATCH /api/business/oauth-credentials/
|
||||
|
||||
Credentials are stored per-provider:
|
||||
{
|
||||
"google": {"client_id": "...", "client_secret": "..."},
|
||||
"facebook": {"client_id": "...", "client_secret": "..."},
|
||||
...
|
||||
}
|
||||
"""
|
||||
user = request.user
|
||||
tenant = user.tenant
|
||||
|
||||
# Platform users don't have a tenant
|
||||
if not tenant:
|
||||
return Response({'error': 'No business found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Only owners can view/update credentials
|
||||
if user.role.lower() != 'tenant_owner':
|
||||
return Response({'error': 'Only business owners can manage OAuth credentials'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if request.method == 'GET':
|
||||
# Return credentials with secrets masked
|
||||
credentials = tenant.oauth_credentials or {}
|
||||
masked_credentials = {}
|
||||
for provider, creds in credentials.items():
|
||||
masked_credentials[provider] = {
|
||||
'client_id': creds.get('client_id', ''),
|
||||
# Mask the secret - show first 4 chars if set
|
||||
'client_secret': ('****' + creds.get('client_secret', '')[-4:]) if creds.get('client_secret') else '',
|
||||
'has_secret': bool(creds.get('client_secret')),
|
||||
}
|
||||
return Response({
|
||||
'credentials': masked_credentials,
|
||||
'use_custom_credentials': tenant.oauth_use_custom_credentials,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# PATCH - update credentials
|
||||
data = request.data
|
||||
valid_provider_ids = [p['id'] for p in AVAILABLE_OAUTH_PROVIDERS]
|
||||
|
||||
if 'credentials' in data:
|
||||
credentials = data['credentials']
|
||||
if not isinstance(credentials, dict):
|
||||
return Response({'error': 'credentials must be an object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Merge with existing credentials (don't overwrite secrets if not provided)
|
||||
existing_credentials = tenant.oauth_credentials or {}
|
||||
for provider, creds in credentials.items():
|
||||
if provider not in valid_provider_ids:
|
||||
return Response({'error': f'Invalid provider: {provider}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if provider not in existing_credentials:
|
||||
existing_credentials[provider] = {}
|
||||
|
||||
if 'client_id' in creds:
|
||||
existing_credentials[provider]['client_id'] = creds['client_id']
|
||||
|
||||
# Only update secret if provided and not masked value
|
||||
if 'client_secret' in creds and creds['client_secret'] and not creds['client_secret'].startswith('****'):
|
||||
existing_credentials[provider]['client_secret'] = creds['client_secret']
|
||||
|
||||
tenant.oauth_credentials = existing_credentials
|
||||
|
||||
if 'use_custom_credentials' in data:
|
||||
tenant.oauth_use_custom_credentials = bool(data['use_custom_credentials'])
|
||||
|
||||
tenant.save()
|
||||
|
||||
# Return masked credentials
|
||||
credentials = tenant.oauth_credentials or {}
|
||||
masked_credentials = {}
|
||||
for provider, creds in credentials.items():
|
||||
masked_credentials[provider] = {
|
||||
'client_id': creds.get('client_id', ''),
|
||||
'client_secret': ('****' + creds.get('client_secret', '')[-4:]) if creds.get('client_secret') else '',
|
||||
'has_secret': bool(creds.get('client_secret')),
|
||||
}
|
||||
|
||||
return Response({
|
||||
'credentials': masked_credentials,
|
||||
'use_custom_credentials': tenant.oauth_use_custom_credentials,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user