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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,6 +22,37 @@ export interface PlatformBusiness {
|
||||
created_on: string;
|
||||
user_count: number;
|
||||
owner: PlatformBusinessOwner | null;
|
||||
max_users: number;
|
||||
max_resources: number;
|
||||
contact_email?: string;
|
||||
phone?: string;
|
||||
// Platform permissions
|
||||
can_manage_oauth_credentials: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessUpdate {
|
||||
name?: string;
|
||||
is_active?: boolean;
|
||||
subscription_tier?: string;
|
||||
max_users?: number;
|
||||
max_resources?: number;
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessCreate {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
subscription_tier?: string;
|
||||
is_active?: boolean;
|
||||
max_users?: number;
|
||||
max_resources?: number;
|
||||
contact_email?: string;
|
||||
phone?: string;
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
// Owner details (optional)
|
||||
owner_email?: string;
|
||||
owner_name?: string;
|
||||
owner_password?: string;
|
||||
}
|
||||
|
||||
export interface PlatformUser {
|
||||
@@ -48,6 +79,33 @@ export const getBusinesses = async (): Promise<PlatformBusiness[]> => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a business (platform admin only)
|
||||
*/
|
||||
export const updateBusiness = async (
|
||||
businessId: number,
|
||||
data: PlatformBusinessUpdate
|
||||
): Promise<PlatformBusiness> => {
|
||||
const response = await apiClient.patch<PlatformBusiness>(
|
||||
`/api/platform/businesses/${businessId}/`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new business (platform admin only)
|
||||
*/
|
||||
export const createBusiness = async (
|
||||
data: PlatformBusinessCreate
|
||||
): Promise<PlatformBusiness> => {
|
||||
const response = await apiClient.post<PlatformBusiness>(
|
||||
'/api/platform/businesses/',
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users (platform admin only)
|
||||
*/
|
||||
@@ -63,3 +121,137 @@ export const getBusinessUsers = async (businessId: number): Promise<PlatformUser
|
||||
const response = await apiClient.get<PlatformUser[]>(`/api/platform/users/?business=${businessId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Invitations
|
||||
// ============================================================================
|
||||
|
||||
export interface TenantInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
token: string;
|
||||
status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'CANCELLED';
|
||||
suggested_business_name: string;
|
||||
subscription_tier: 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE';
|
||||
custom_max_users: number | null;
|
||||
custom_max_resources: number | null;
|
||||
permissions: {
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
can_accept_payments?: boolean;
|
||||
can_use_custom_domain?: boolean;
|
||||
can_white_label?: boolean;
|
||||
can_api_access?: boolean;
|
||||
};
|
||||
personal_message: string;
|
||||
invited_by: number;
|
||||
invited_by_email: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
accepted_at: string | null;
|
||||
created_tenant: number | null;
|
||||
created_tenant_name: string | null;
|
||||
created_user: number | null;
|
||||
created_user_email: string | null;
|
||||
}
|
||||
|
||||
export interface TenantInvitationCreate {
|
||||
email: string;
|
||||
suggested_business_name?: string;
|
||||
subscription_tier: 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE';
|
||||
custom_max_users?: number | null;
|
||||
custom_max_resources?: number | null;
|
||||
permissions?: {
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
can_accept_payments?: boolean;
|
||||
can_use_custom_domain?: boolean;
|
||||
can_white_label?: boolean;
|
||||
can_api_access?: boolean;
|
||||
};
|
||||
personal_message?: string;
|
||||
}
|
||||
|
||||
export interface TenantInvitationDetail {
|
||||
email: string;
|
||||
suggested_business_name: string;
|
||||
subscription_tier: string;
|
||||
effective_max_users: number;
|
||||
effective_max_resources: number;
|
||||
permissions: {
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
can_accept_payments?: boolean;
|
||||
can_use_custom_domain?: boolean;
|
||||
can_white_label?: boolean;
|
||||
can_api_access?: boolean;
|
||||
};
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface TenantInvitationAccept {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
business_name: string;
|
||||
subdomain: string;
|
||||
contact_email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tenant invitations (platform admin only)
|
||||
*/
|
||||
export const getTenantInvitations = async (): Promise<TenantInvitation[]> => {
|
||||
const response = await apiClient.get<TenantInvitation[]>('/api/platform/tenant-invitations/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tenant invitation (platform admin only)
|
||||
*/
|
||||
export const createTenantInvitation = async (
|
||||
data: TenantInvitationCreate
|
||||
): Promise<TenantInvitation> => {
|
||||
const response = await apiClient.post<TenantInvitation>(
|
||||
'/api/platform/tenant-invitations/',
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resend a tenant invitation (platform admin only)
|
||||
*/
|
||||
export const resendTenantInvitation = async (invitationId: number): Promise<void> => {
|
||||
await apiClient.post(`/api/platform/tenant-invitations/${invitationId}/resend/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a tenant invitation (platform admin only)
|
||||
*/
|
||||
export const cancelTenantInvitation = async (invitationId: number): Promise<void> => {
|
||||
await apiClient.post(`/api/platform/tenant-invitations/${invitationId}/cancel/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get invitation details by token (public, no auth required)
|
||||
*/
|
||||
export const getInvitationByToken = async (token: string): Promise<TenantInvitationDetail> => {
|
||||
const response = await apiClient.get<TenantInvitationDetail>(
|
||||
`/api/platform/tenant-invitations/token/${token}/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Accept an invitation by token (public, no auth required)
|
||||
*/
|
||||
export const acceptInvitation = async (
|
||||
token: string,
|
||||
data: TenantInvitationAccept
|
||||
): Promise<{ detail: string }> => {
|
||||
const response = await apiClient.post<{ detail: string }>(
|
||||
`/api/platform/tenant-invitations/token/${token}/accept/`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user