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

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Business, User, CustomDomain } from '../types';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil } from 'lucide-react';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil, Upload, Image as ImageIcon } from 'lucide-react';
import DomainPurchase from '../components/DomainPurchase';
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
@@ -365,6 +365,7 @@ const SettingsPage: React.FC = () => {
enabledProviders: [] as string[],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: false,
});
// Custom Domains
@@ -405,28 +406,26 @@ const SettingsPage: React.FC = () => {
// Update OAuth settings when data loads
useEffect(() => {
if (oauthData?.businessSettings) {
setOAuthSettings(oauthData.businessSettings);
if (oauthData?.settings) {
setOAuthSettings(oauthData.settings);
}
}, [oauthData]);
// Update OAuth credentials when data loads
useEffect(() => {
if (oauthCredentials) {
setUseCustomCredentials(oauthCredentials.use_custom_credentials || false);
if (oauthCredentials.google || oauthCredentials.apple || oauthCredentials.facebook ||
oauthCredentials.linkedin || oauthCredentials.microsoft || oauthCredentials.twitter ||
oauthCredentials.twitch) {
setCredentials({
google: oauthCredentials.google || { client_id: '', client_secret: '' },
apple: oauthCredentials.apple || { client_id: '', client_secret: '', team_id: '', key_id: '' },
facebook: oauthCredentials.facebook || { client_id: '', client_secret: '' },
linkedin: oauthCredentials.linkedin || { client_id: '', client_secret: '' },
microsoft: oauthCredentials.microsoft || { client_id: '', client_secret: '', tenant_id: '' },
twitter: oauthCredentials.twitter || { client_id: '', client_secret: '' },
twitch: oauthCredentials.twitch || { client_id: '', client_secret: '' },
});
}
setUseCustomCredentials(oauthCredentials.useCustomCredentials || false);
// Map credentials from the response to local state
const creds = oauthCredentials.credentials || {};
setCredentials({
google: creds.google || { client_id: '', client_secret: '' },
apple: creds.apple || { client_id: '', client_secret: '', team_id: '', key_id: '' },
facebook: creds.facebook || { client_id: '', client_secret: '' },
linkedin: creds.linkedin || { client_id: '', client_secret: '' },
microsoft: creds.microsoft || { client_id: '', client_secret: '', tenant_id: '' },
twitter: creds.twitter || { client_id: '', client_secret: '' },
twitch: creds.twitch || { client_id: '', client_secret: '' },
});
}
}, [oauthCredentials]);
@@ -716,7 +715,7 @@ const SettingsPage: React.FC = () => {
{/* Logo Upload */}
<div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Image size={16} className="text-blue-500" />
<ImageIcon size={16} className="text-blue-500" />
Brand Logos
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
@@ -771,7 +770,7 @@ const SettingsPage: React.FC = () => {
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={32} className="mx-auto mb-2" />
<ImageIcon size={32} className="mx-auto mb-2" />
<p className="text-xs">Drop image here</p>
</div>
</div>
@@ -872,7 +871,7 @@ const SettingsPage: React.FC = () => {
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={24} className="mx-auto mb-1" />
<ImageIcon size={24} className="mx-auto mb-1" />
<p className="text-xs">Drop image here</p>
</div>
</div>
@@ -926,7 +925,7 @@ const SettingsPage: React.FC = () => {
</label>
<div
className="w-full max-w-xs p-6 rounded-xl"
style={{ backgroundColor: formState.primaryColor }}
style={{ background: `linear-gradient(to bottom right, ${formState.primaryColor}, ${formState.secondaryColor || formState.primaryColor})` }}
>
<div className="flex items-center gap-3">
{/* Logo-only mode: full width */}
@@ -1100,7 +1099,7 @@ const SettingsPage: React.FC = () => {
<div className="bg-gray-100 dark:bg-gray-900 rounded-xl p-3 overflow-hidden">
<div
className="rounded-t-lg p-2.5 flex items-center justify-between"
style={{ backgroundColor: formState.primaryColor }}
style={{ background: `linear-gradient(to bottom right, ${formState.primaryColor}, ${formState.secondaryColor || formState.primaryColor})` }}
>
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-white rounded font-bold text-[10px] flex items-center justify-center" style={{ color: formState.primaryColor }}>
@@ -1441,13 +1440,13 @@ const SettingsPage: React.FC = () => {
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{oauthData.availableProviders.map((provider) => {
const isEnabled = oauthSettings.enabledProviders.includes(provider);
const info = providerInfo[provider] || { name: provider, icon: '🔐' };
const isEnabled = oauthSettings.enabledProviders.includes(provider.id);
const info = providerInfo[provider.id] || { name: provider.name, icon: '🔐' };
return (
<button
key={provider}
key={provider.id}
type="button"
onClick={() => toggleProvider(provider)}
onClick={() => toggleProvider(provider.id)}
className={`relative p-3 rounded-lg border-2 transition-all text-left ${
isEnabled
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
@@ -1515,45 +1514,30 @@ const SettingsPage: React.FC = () => {
)}
</section>
{/* Custom OAuth Credentials */}
{/* Custom OAuth Credentials - Only shown if platform has enabled this permission */}
{business.canManageOAuthCredentials && (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Key size={20} className="text-purple-500" />
Custom OAuth Credentials
{business.plan === 'Free' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">
<Crown size={12} /> Pro
</span>
)}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Use your own OAuth app credentials for complete branding control
</p>
</div>
{business.plan !== 'Free' && (
<button
onClick={handleCredentialsSave}
disabled={credentialsLoading || updateCredentialsMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={16} />
{updateCredentialsMutation.isPending ? 'Saving...' : 'Save'}
</button>
)}
<button
onClick={handleCredentialsSave}
disabled={credentialsLoading || updateCredentialsMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={16} />
{updateCredentialsMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
{business.plan === 'Free' ? (
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to use your own OAuth credentials for custom branding and higher rate limits.
</p>
<button className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
<Crown size={14} /> View Plans
</button>
</div>
) : credentialsLoading ? (
{credentialsLoading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
@@ -1684,6 +1668,7 @@ const SettingsPage: React.FC = () => {
</div>
)}
</section>
)}
</>
)}
</div>
@@ -1882,14 +1867,16 @@ const SettingsPage: React.FC = () => {
<div className="flex items-center gap-3">
<button
onClick={handleCancel}
className="flex items-center gap-2 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
className="flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium border"
style={{ color: formState.secondaryColor, borderColor: formState.secondaryColor }}
>
<X size={18} />
Cancel Changes
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md font-medium"
className="flex items-center gap-2 px-6 py-3 text-white rounded-lg transition-colors shadow-md font-medium hover:opacity-90"
style={{ backgroundColor: formState.primaryColor }}
>
<Save size={18} />
Save Changes