Files
smoothschedule/frontend/src/pages/platform/components/TenantInviteModal.tsx
poduck 6afa3d7415 Refactor billing system: add-ons in wizard, remove business_tier, move to top-level app
- Add add-ons step to plan creation wizard (step 4 of 5)
- Remove redundant business_tier field from both billing systems:
  - commerce.billing.PlanVersion (new system)
  - platform.admin.SubscriptionPlan (legacy system)
- Move billing app from commerce.billing to top-level smoothschedule.billing
- Create BillingManagement page at /platform/billing with sidebar link
- Update plan matching logic to use plan.name instead of business_tier

Frontend:
- Add BillingManagement.tsx page
- Add BillingPlansTab.tsx with unified plan wizard
- Add useBillingAdmin.ts hooks
- Update TenantInviteModal, BusinessEditModal, BillingSettings to use plan.name
- Remove business_tier from usePlatformSettings, payments.ts types

Backend:
- Move billing app to smoothschedule/billing/
- Add migrations 0006-0009 for plan version settings, feature seeding, business_tier removal
- Add platform_admin migration 0013 to remove business_tier
- Update seed_subscription_plans command
- Update tasks.py to map tier by plan name

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 01:25:43 -05:00

689 lines
32 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { X, Send, Mail, Building2, ChevronDown, ChevronUp } from 'lucide-react';
import { useCreateTenantInvitation } from '../../../hooks/usePlatform';
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
interface TenantInviteModalProps {
isOpen: boolean;
onClose: () => void;
}
// Default tier settings - used when no subscription plans are loaded
const TIER_DEFAULTS: Record<string, {
max_users: number;
max_resources: number;
can_manage_oauth_credentials: boolean;
can_accept_payments: boolean;
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
can_add_video_conferencing: boolean;
can_use_sms_reminders: boolean;
can_use_masked_phone_numbers: boolean;
can_use_plugins: boolean;
can_use_tasks: boolean;
can_create_plugins: boolean;
can_use_webhooks: boolean;
can_use_calendar_sync: boolean;
can_export_data: boolean;
can_require_2fa: boolean;
}> = {
FREE: {
max_users: 2,
max_resources: 5,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
can_add_video_conferencing: false,
can_use_sms_reminders: false,
can_use_masked_phone_numbers: false,
can_use_plugins: true,
can_use_tasks: true,
can_create_plugins: false,
can_use_webhooks: false,
can_use_calendar_sync: false,
can_export_data: false,
can_require_2fa: false,
},
STARTER: {
max_users: 5,
max_resources: 15,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
can_add_video_conferencing: false,
can_use_sms_reminders: false,
can_use_masked_phone_numbers: false,
can_use_plugins: true,
can_use_tasks: true,
can_create_plugins: false,
can_use_webhooks: false,
can_use_calendar_sync: false,
can_export_data: false,
can_require_2fa: false,
},
PROFESSIONAL: {
max_users: 15,
max_resources: 50,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: false,
can_api_access: true,
can_add_video_conferencing: true,
can_use_sms_reminders: true,
can_use_masked_phone_numbers: false,
can_use_plugins: true,
can_use_tasks: true,
can_create_plugins: false,
can_use_webhooks: true,
can_use_calendar_sync: true,
can_export_data: true,
can_require_2fa: false,
},
ENTERPRISE: {
max_users: -1, // unlimited
max_resources: -1, // unlimited
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
can_add_video_conferencing: true,
can_use_sms_reminders: true,
can_use_masked_phone_numbers: true,
can_use_plugins: true,
can_use_tasks: true,
can_create_plugins: true,
can_use_webhooks: true,
can_use_calendar_sync: true,
can_export_data: true,
can_require_2fa: true,
},
};
const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }) => {
const createInvitationMutation = useCreateTenantInvitation();
const { data: subscriptionPlans } = useSubscriptionPlans();
const [inviteForm, setInviteForm] = useState({
email: '',
suggested_business_name: '',
subscription_tier: 'PROFESSIONAL' as 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE',
use_custom_limits: false,
// Limits
max_users: 15,
max_resources: 50,
// Permissions
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: false,
can_api_access: true,
can_add_video_conferencing: true,
can_use_sms_reminders: true,
can_use_masked_phone_numbers: false,
can_use_plugins: true,
can_use_tasks: true,
can_create_plugins: false,
can_use_webhooks: true,
can_use_calendar_sync: true,
can_export_data: true,
can_require_2fa: false,
personal_message: '',
});
const [inviteError, setInviteError] = useState<string | null>(null);
const [inviteSuccess, setInviteSuccess] = useState(false);
// Get tier defaults from subscription plans or fallback to static defaults
const getTierDefaults = (tier: string) => {
// Try to find matching subscription plan
if (subscriptionPlans) {
const tierNameMap: Record<string, string> = {
'FREE': 'Free',
'STARTER': 'Starter',
'PROFESSIONAL': 'Professional',
'ENTERPRISE': 'Enterprise',
};
const plan = subscriptionPlans.find(p =>
p.name === tierNameMap[tier] || p.name === tier
);
if (plan) {
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
return {
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
can_manage_oauth_credentials: plan.permissions?.can_manage_oauth_credentials ?? staticDefaults.can_manage_oauth_credentials,
can_accept_payments: plan.permissions?.can_accept_payments ?? staticDefaults.can_accept_payments,
can_use_custom_domain: plan.permissions?.can_use_custom_domain ?? staticDefaults.can_use_custom_domain,
can_white_label: plan.permissions?.can_white_label ?? staticDefaults.can_white_label,
can_api_access: plan.permissions?.can_api_access ?? staticDefaults.can_api_access,
can_add_video_conferencing: plan.permissions?.video_conferencing ?? staticDefaults.can_add_video_conferencing,
can_use_sms_reminders: plan.permissions?.sms_reminders ?? staticDefaults.can_use_sms_reminders,
can_use_masked_phone_numbers: plan.permissions?.masked_calling ?? staticDefaults.can_use_masked_phone_numbers,
can_use_plugins: plan.permissions?.plugins ?? staticDefaults.can_use_plugins,
can_use_tasks: plan.permissions?.tasks ?? staticDefaults.can_use_tasks,
can_create_plugins: plan.permissions?.can_create_plugins ?? staticDefaults.can_create_plugins,
can_use_webhooks: plan.permissions?.webhooks ?? staticDefaults.can_use_webhooks,
can_use_calendar_sync: plan.permissions?.calendar_sync ?? staticDefaults.can_use_calendar_sync,
can_export_data: plan.permissions?.export_data ?? staticDefaults.can_export_data,
can_require_2fa: plan.permissions?.two_factor_auth ?? staticDefaults.can_require_2fa,
};
}
}
// Fallback to static defaults
return TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
};
// Handle subscription tier change - auto-update limits and permissions
const handleTierChange = (newTier: string) => {
const defaults = getTierDefaults(newTier);
setInviteForm(prev => ({
...prev,
subscription_tier: newTier as any,
...defaults,
}));
};
// Initialize defaults when modal opens or subscription plans load
useEffect(() => {
if (isOpen) {
const defaults = getTierDefaults(inviteForm.subscription_tier);
setInviteForm(prev => ({
...prev,
...defaults,
}));
}
}, [isOpen, subscriptionPlans]);
const resetForm = () => {
const defaults = getTierDefaults('PROFESSIONAL');
setInviteForm({
email: '',
suggested_business_name: '',
subscription_tier: 'PROFESSIONAL',
use_custom_limits: false,
...defaults,
personal_message: '',
});
setInviteError(null);
setInviteSuccess(false);
};
const handleClose = () => {
resetForm();
onClose();
};
const handleInviteSend = () => {
setInviteError(null);
setInviteSuccess(false);
// Validation
if (!inviteForm.email.trim()) {
setInviteError('Email address is required');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteForm.email)) {
setInviteError('Please enter a valid email address');
return;
}
// Build invitation data
const data: any = {
email: inviteForm.email,
subscription_tier: inviteForm.subscription_tier,
};
if (inviteForm.suggested_business_name.trim()) {
data.suggested_business_name = inviteForm.suggested_business_name.trim();
}
// If using custom limits, include all the overrides
if (inviteForm.use_custom_limits) {
data.custom_max_users = inviteForm.max_users;
data.custom_max_resources = inviteForm.max_resources;
data.permissions = {
can_manage_oauth_credentials: inviteForm.can_manage_oauth_credentials,
can_accept_payments: inviteForm.can_accept_payments,
can_use_custom_domain: inviteForm.can_use_custom_domain,
can_white_label: inviteForm.can_white_label,
can_api_access: inviteForm.can_api_access,
};
data.limits = {
can_add_video_conferencing: inviteForm.can_add_video_conferencing,
can_use_sms_reminders: inviteForm.can_use_sms_reminders,
can_use_masked_phone_numbers: inviteForm.can_use_masked_phone_numbers,
can_use_plugins: inviteForm.can_use_plugins,
can_use_tasks: inviteForm.can_use_tasks,
can_create_plugins: inviteForm.can_create_plugins,
can_use_webhooks: inviteForm.can_use_webhooks,
can_use_calendar_sync: inviteForm.can_use_calendar_sync,
can_export_data: inviteForm.can_export_data,
can_require_2fa: inviteForm.can_require_2fa,
};
}
if (inviteForm.personal_message.trim()) {
data.personal_message = inviteForm.personal_message.trim();
}
createInvitationMutation.mutate(data, {
onSuccess: () => {
setInviteSuccess(true);
setTimeout(() => {
handleClose();
}, 2000);
},
onError: (error: any) => {
setInviteError(error.response?.data?.detail || error.message || 'Failed to send invitation');
},
});
};
if (!isOpen) 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-2xl mx-4 max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<Send size={24} className="text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Invite New Tenant</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Send an invitation to create a new business</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 space-y-6">
{inviteError && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{inviteError}</p>
</div>
)}
{inviteSuccess && (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-sm text-green-600 dark:text-green-400">Invitation sent successfully!</p>
</div>
)}
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Address *
</label>
<div className="relative">
<Mail size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="owner@business.com"
/>
</div>
</div>
{/* Suggested Business Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Suggested Business Name (Optional)
</label>
<div className="relative">
<Building2 size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={inviteForm.suggested_business_name}
onChange={(e) => setInviteForm({ ...inviteForm, suggested_business_name: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Owner can change this during onboarding"
/>
</div>
</div>
{/* Subscription Tier */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Subscription Tier
</label>
<select
value={inviteForm.subscription_tier}
onChange={(e) => handleTierChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="FREE">Free Trial</option>
<option value="STARTER">Starter</option>
<option value="PROFESSIONAL">Professional</option>
<option value="ENTERPRISE">Enterprise</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Tier defaults are loaded from platform subscription settings
</p>
</div>
{/* Override Tier Limits Toggle */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<button
type="button"
onClick={() => setInviteForm({ ...inviteForm, use_custom_limits: !inviteForm.use_custom_limits })}
className="flex items-center justify-between w-full p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={inviteForm.use_custom_limits}
onChange={(e) => setInviteForm({ ...inviteForm, use_custom_limits: e.target.checked })}
className="rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500"
onClick={(e) => e.stopPropagation()}
/>
<div className="text-left">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Override Tier Limits
</span>
<p className="text-xs text-gray-500 dark:text-gray-400">
Customize limits and permissions for this tenant
</p>
</div>
</div>
{inviteForm.use_custom_limits ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</button>
{/* Sliding Custom Limits Panel */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
inviteForm.use_custom_limits ? 'max-h-[2000px] opacity-100 mt-4' : 'max-h-0 opacity-0'
}`}
>
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Limits Configuration */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Limits Configuration
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Use -1 for unlimited
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Max Users</label>
<input
type="number"
min="-1"
value={inviteForm.max_users}
onChange={(e) => setInviteForm({ ...inviteForm, max_users: parseInt(e.target.value) || 0 })}
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"
/>
</div>
<div>
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Max Resources</label>
<input
type="number"
min="-1"
value={inviteForm.max_resources}
onChange={(e) => setInviteForm({ ...inviteForm, max_resources: parseInt(e.target.value) || 0 })}
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"
/>
</div>
</div>
</div>
{/* Payments & Revenue */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Payments & Revenue
</h4>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_accept_payments}
onChange={(e) => setInviteForm({ ...inviteForm, can_accept_payments: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
</label>
</div>
</div>
{/* Communication */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Communication
</h4>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_use_sms_reminders}
onChange={(e) => setInviteForm({ ...inviteForm, can_use_sms_reminders: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_use_masked_phone_numbers}
onChange={(e) => setInviteForm({ ...inviteForm, can_use_masked_phone_numbers: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
</label>
</div>
</div>
{/* Customization */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Customization
</h4>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_use_custom_domain}
onChange={(e) => setInviteForm({ ...inviteForm, can_use_custom_domain: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_white_label}
onChange={(e) => setInviteForm({ ...inviteForm, can_white_label: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
</label>
</div>
</div>
{/* Plugins & Automation */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Plugins & Automation
</h4>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_use_plugins}
onChange={(e) => {
const checked = e.target.checked;
setInviteForm(prev => ({
...prev,
can_use_plugins: checked,
...(checked ? {} : { can_use_tasks: false, can_create_plugins: false })
}));
}}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use Plugins</span>
</label>
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg ${inviteForm.can_use_plugins ? 'hover:bg-white dark:hover:bg-gray-700 cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={inviteForm.can_use_tasks}
onChange={(e) => setInviteForm({ ...inviteForm, can_use_tasks: e.target.checked })}
disabled={!inviteForm.can_use_plugins}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Scheduled Tasks</span>
</label>
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg ${inviteForm.can_use_plugins ? 'hover:bg-white dark:hover:bg-gray-700 cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={inviteForm.can_create_plugins}
onChange={(e) => setInviteForm({ ...inviteForm, can_create_plugins: e.target.checked })}
disabled={!inviteForm.can_use_plugins}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
</label>
</div>
</div>
{/* Advanced Features */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Advanced Features
</h4>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_api_access}
onChange={(e) => setInviteForm({ ...inviteForm, can_api_access: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_use_webhooks}
onChange={(e) => setInviteForm({ ...inviteForm, can_use_webhooks: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_use_calendar_sync}
onChange={(e) => setInviteForm({ ...inviteForm, can_use_calendar_sync: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_export_data}
onChange={(e) => setInviteForm({ ...inviteForm, can_export_data: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_add_video_conferencing}
onChange={(e) => setInviteForm({ ...inviteForm, can_add_video_conferencing: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Video Conferencing</span>
</label>
</div>
</div>
{/* Enterprise */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Enterprise
</h4>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_manage_oauth_credentials}
onChange={(e) => setInviteForm({ ...inviteForm, can_manage_oauth_credentials: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Manage OAuth</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-white dark:hover:bg-gray-700 cursor-pointer">
<input
type="checkbox"
checked={inviteForm.can_require_2fa}
onChange={(e) => setInviteForm({ ...inviteForm, can_require_2fa: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Require 2FA</span>
</label>
</div>
</div>
</div>
</div>
</div>
{/* Personal Message */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Personal Message (Optional)
</label>
<textarea
value={inviteForm.personal_message}
onChange={(e) => setInviteForm({ ...inviteForm, personal_message: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Add a personal note to the invitation email..."
/>
</div>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg font-medium text-sm transition-colors"
>
Cancel
</button>
<button
onClick={handleInviteSend}
disabled={createInvitationMutation.isPending || inviteSuccess}
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"
>
<Send size={16} />
{createInvitationMutation.isPending ? 'Sending...' : inviteSuccess ? 'Sent!' : 'Send Invitation'}
</button>
</div>
</div>
</div>
);
};
export default TenantInviteModal;