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

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Key } from 'lucide-react';
import { useUpdateBusiness } from '../../../hooks/usePlatform';
import { PlatformBusiness } from '../../../api/platform';
interface BusinessEditModalProps {
business: PlatformBusiness | null;
isOpen: boolean;
onClose: () => void;
}
const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen, onClose }) => {
const updateBusinessMutation = useUpdateBusiness();
const [editForm, setEditForm] = useState({
name: '',
is_active: true,
subscription_tier: 'FREE',
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
});
// Update form when business changes
useEffect(() => {
if (business) {
setEditForm({
name: business.name,
is_active: business.is_active,
subscription_tier: business.tier,
max_users: business.max_users || 5,
max_resources: business.max_resources || 10,
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
});
}
}, [business]);
const handleEditSave = () => {
if (!business) return;
updateBusinessMutation.mutate(
{
businessId: business.id,
data: editForm,
},
{
onSuccess: () => {
onClose();
},
}
);
};
if (!isOpen || !business) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Edit Business: {business.name}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X size={20} />
</button>
</div>
{/* Modal Body */}
<div className="p-4 space-y-4">
{/* Business Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Business Name
</label>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{/* Status */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Active Status
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Inactive businesses cannot be accessed
</p>
</div>
<button
type="button"
onClick={() => setEditForm({ ...editForm, is_active: !editForm.is_active })}
className={`${editForm.is_active ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500`}
role="switch"
>
<span className={`${editForm.is_active ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
{/* Subscription Tier */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subscription Tier
</label>
<select
value={editForm.subscription_tier}
onChange={(e) => setEditForm({ ...editForm, subscription_tier: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="FREE">Free Trial</option>
<option value="STARTER">Starter</option>
<option value="PROFESSIONAL">Professional</option>
<option value="ENTERPRISE">Enterprise</option>
</select>
</div>
{/* Limits */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users
</label>
<input
type="number"
min="1"
value={editForm.max_users}
onChange={(e) => setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Resources
</label>
<input
type="number"
min="1"
value={editForm.max_resources}
onChange={(e) => setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
{/* Permissions Section */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Key size={16} className="text-purple-500" />
Platform Permissions
</h4>
{/* Can Manage OAuth Credentials */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Manage OAuth Credentials
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Allow this business to configure their own OAuth app credentials
</p>
</div>
<button
type="button"
onClick={() => setEditForm({ ...editForm, can_manage_oauth_credentials: !editForm.can_manage_oauth_credentials })}
className={`${editForm.can_manage_oauth_credentials ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
role="switch"
>
<span className={`${editForm.can_manage_oauth_credentials ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium text-sm transition-colors"
>
Cancel
</button>
<button
onClick={handleEditSave}
disabled={updateBusinessMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={16} />
{updateBusinessMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
};
export default BusinessEditModal;