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

@@ -3,7 +3,7 @@
*/
import apiClient from './client';
import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, BusinessOAuthCredentials } from '../types';
import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, BusinessOAuthCredentialsResponse } from '../types';
/**
* Get all resources for the current business
@@ -26,20 +26,27 @@ export const getBusinessUsers = async (): Promise<User[]> => {
*/
export const getBusinessOAuthSettings = async (): Promise<BusinessOAuthSettingsResponse> => {
const response = await apiClient.get<{
business_settings: {
oauth_enabled_providers: string[];
oauth_allow_registration: boolean;
oauth_auto_link_by_email: boolean;
settings: {
enabled_providers: string[];
allow_registration: boolean;
auto_link_by_email: boolean;
use_custom_credentials: boolean;
};
available_providers: string[];
available_providers: Array<{
id: string;
name: string;
icon: string;
description: string;
}>;
}>('/api/business/oauth-settings/');
// Transform snake_case to camelCase
return {
businessSettings: {
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
allowRegistration: response.data.business_settings.oauth_allow_registration,
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
settings: {
enabledProviders: response.data.settings.enabled_providers || [],
allowRegistration: response.data.settings.allow_registration,
autoLinkByEmail: response.data.settings.auto_link_by_email,
useCustomCredentials: response.data.settings.use_custom_credentials,
},
availableProviders: response.data.available_providers || [],
};
@@ -55,30 +62,40 @@ export const updateBusinessOAuthSettings = async (
const backendData: Record<string, any> = {};
if (settings.enabledProviders !== undefined) {
backendData.oauth_enabled_providers = settings.enabledProviders;
backendData.enabled_providers = settings.enabledProviders;
}
if (settings.allowRegistration !== undefined) {
backendData.oauth_allow_registration = settings.allowRegistration;
backendData.allow_registration = settings.allowRegistration;
}
if (settings.autoLinkByEmail !== undefined) {
backendData.oauth_auto_link_by_email = settings.autoLinkByEmail;
backendData.auto_link_by_email = settings.autoLinkByEmail;
}
if (settings.useCustomCredentials !== undefined) {
backendData.use_custom_credentials = settings.useCustomCredentials;
}
const response = await apiClient.patch<{
business_settings: {
oauth_enabled_providers: string[];
oauth_allow_registration: boolean;
oauth_auto_link_by_email: boolean;
settings: {
enabled_providers: string[];
allow_registration: boolean;
auto_link_by_email: boolean;
use_custom_credentials: boolean;
};
available_providers: string[];
}>('/api/business/oauth-settings/update/', backendData);
available_providers: Array<{
id: string;
name: string;
icon: string;
description: string;
}>;
}>('/api/business/oauth-settings/', backendData);
// Transform snake_case to camelCase
return {
businessSettings: {
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
allowRegistration: response.data.business_settings.oauth_allow_registration,
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
settings: {
enabledProviders: response.data.settings.enabled_providers || [],
allowRegistration: response.data.settings.allow_registration,
autoLinkByEmail: response.data.settings.auto_link_by_email,
useCustomCredentials: response.data.settings.use_custom_credentials,
},
availableProviders: response.data.available_providers || [],
};
@@ -87,20 +104,51 @@ export const updateBusinessOAuthSettings = async (
/**
* Get business OAuth credentials (custom credentials for paid tiers)
*/
export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCredentials> => {
const response = await apiClient.get<BusinessOAuthCredentials>('/api/business/oauth-credentials/');
return response.data;
export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCredentialsResponse> => {
const response = await apiClient.get<{
credentials: Record<string, {
client_id: string;
client_secret: string;
has_secret: boolean;
}>;
use_custom_credentials: boolean;
}>('/api/business/oauth-credentials/');
return {
credentials: response.data.credentials || {},
useCustomCredentials: response.data.use_custom_credentials,
};
};
/**
* Update business OAuth credentials (custom credentials for paid tiers)
*/
export const updateBusinessOAuthCredentials = async (
credentials: Partial<BusinessOAuthCredentials>
): Promise<BusinessOAuthCredentials> => {
const response = await apiClient.patch<BusinessOAuthCredentials>(
'/api/business/oauth-credentials/update/',
credentials
);
return response.data;
data: {
credentials?: Record<string, { client_id?: string; client_secret?: string }>;
useCustomCredentials?: boolean;
}
): Promise<BusinessOAuthCredentialsResponse> => {
const backendData: Record<string, any> = {};
if (data.credentials !== undefined) {
backendData.credentials = data.credentials;
}
if (data.useCustomCredentials !== undefined) {
backendData.use_custom_credentials = data.useCustomCredentials;
}
const response = await apiClient.patch<{
credentials: Record<string, {
client_id: string;
client_secret: string;
has_secret: boolean;
}>;
use_custom_credentials: boolean;
}>('/api/business/oauth-credentials/', backendData);
return {
credentials: response.data.credentials || {},
useCustomCredentials: response.data.use_custom_credentials,
};
};