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

@@ -46,6 +46,8 @@ export const useCurrentBusiness = () => {
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
// Platform-controlled permissions
canManageOAuthCredentials: data.can_manage_oauth_credentials || false,
};
},
});

View File

@@ -13,6 +13,7 @@ export const useBusinessOAuthSettings = () => {
return useQuery<BusinessOAuthSettingsResponse>({
queryKey: ['businessOAuthSettings'],
queryFn: getBusinessOAuthSettings,
retry: false, // Don't retry on 404
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -4,15 +4,17 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthCredentials, updateBusinessOAuthCredentials } from '../api/business';
import { BusinessOAuthCredentials } from '../types';
import { BusinessOAuthCredentialsResponse } from '../types';
/**
* Fetch business OAuth credentials
*/
export const useBusinessOAuthCredentials = () => {
return useQuery({
return useQuery<BusinessOAuthCredentialsResponse>({
queryKey: ['businessOAuthCredentials'],
queryFn: getBusinessOAuthCredentials,
retry: false, // Don't retry on 404
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
@@ -23,8 +25,10 @@ export const useUpdateBusinessOAuthCredentials = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: Partial<BusinessOAuthCredentials>) =>
updateBusinessOAuthCredentials(credentials),
mutationFn: (data: {
credentials?: Record<string, { client_id?: string; client_secret?: string }>;
useCustomCredentials?: boolean;
}) => updateBusinessOAuthCredentials(data),
onSuccess: (data) => {
queryClient.setQueryData(['businessOAuthCredentials'], data);
},

View File

@@ -19,6 +19,8 @@ export const useCustomDomains = () => {
return useQuery<CustomDomain[], Error>({
queryKey: ['customDomains'],
queryFn: getCustomDomains,
retry: false, // Don't retry on 404
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -3,8 +3,24 @@
* React Query hooks for platform-level operations
*/
import { useQuery } from '@tanstack/react-query';
import { getBusinesses, getUsers, getBusinessUsers } from '../api/platform';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getBusinesses,
getUsers,
getBusinessUsers,
updateBusiness,
createBusiness,
PlatformBusinessUpdate,
PlatformBusinessCreate,
getTenantInvitations,
createTenantInvitation,
resendTenantInvitation,
cancelTenantInvitation,
getInvitationByToken,
acceptInvitation,
TenantInvitationCreate,
TenantInvitationAccept
} from '../api/platform';
/**
* Hook to get all businesses (platform admin only)
@@ -39,3 +55,116 @@ export const useBusinessUsers = (businessId: number | null) => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update a business (platform admin only)
*/
export const useUpdateBusiness = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ businessId, data }: { businessId: number; data: PlatformBusinessUpdate }) =>
updateBusiness(businessId, data),
onSuccess: () => {
// Invalidate and refetch businesses list
queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
},
});
};
/**
* Hook to create a new business (platform admin only)
*/
export const useCreateBusiness = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: PlatformBusinessCreate) => createBusiness(data),
onSuccess: () => {
// Invalidate and refetch businesses list
queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
},
});
};
// ============================================================================
// Tenant Invitation Hooks
// ============================================================================
/**
* Hook to get all tenant invitations (platform admin only)
*/
export const useTenantInvitations = () => {
return useQuery({
queryKey: ['platform', 'tenant-invitations'],
queryFn: getTenantInvitations,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to create a tenant invitation (platform admin only)
*/
export const useCreateTenantInvitation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TenantInvitationCreate) => createTenantInvitation(data),
onSuccess: () => {
// Invalidate invitations list
queryClient.invalidateQueries({ queryKey: ['platform', 'tenant-invitations'] });
},
});
};
/**
* Hook to resend a tenant invitation (platform admin only)
*/
export const useResendTenantInvitation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (invitationId: number) => resendTenantInvitation(invitationId),
onSuccess: () => {
// Invalidate invitations list
queryClient.invalidateQueries({ queryKey: ['platform', 'tenant-invitations'] });
},
});
};
/**
* Hook to cancel a tenant invitation (platform admin only)
*/
export const useCancelTenantInvitation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (invitationId: number) => cancelTenantInvitation(invitationId),
onSuccess: () => {
// Invalidate invitations list
queryClient.invalidateQueries({ queryKey: ['platform', 'tenant-invitations'] });
},
});
};
/**
* Hook to get invitation details by token (public, no auth required)
*/
export const useInvitationByToken = (token: string | null) => {
return useQuery({
queryKey: ['tenant-invitation', token],
queryFn: () => getInvitationByToken(token!),
enabled: !!token,
retry: false, // Don't retry on 404/expired invitations
});
};
/**
* Hook to accept an invitation (public, no auth required)
*/
export const useAcceptInvitation = () => {
return useMutation({
mutationFn: ({ token, data }: { token: string; data: TenantInvitationAccept }) =>
acceptInvitation(token, data),
});
};