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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user