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:
407
frontend/src/pages/platform/components/BusinessCreateModal.tsx
Normal file
407
frontend/src/pages/platform/components/BusinessCreateModal.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Building2, Key, User, Mail, Lock } from 'lucide-react';
|
||||
import { useCreateBusiness } from '../../../hooks/usePlatform';
|
||||
|
||||
interface BusinessCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClose }) => {
|
||||
const createBusinessMutation = useCreateBusiness();
|
||||
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
subdomain: '',
|
||||
subscription_tier: 'FREE',
|
||||
is_active: true,
|
||||
max_users: 5,
|
||||
max_resources: 10,
|
||||
contact_email: '',
|
||||
phone: '',
|
||||
can_manage_oauth_credentials: false,
|
||||
// Owner fields
|
||||
create_owner: false,
|
||||
owner_email: '',
|
||||
owner_name: '',
|
||||
owner_password: '',
|
||||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateForm({
|
||||
name: '',
|
||||
subdomain: '',
|
||||
subscription_tier: 'FREE',
|
||||
is_active: true,
|
||||
max_users: 5,
|
||||
max_resources: 10,
|
||||
contact_email: '',
|
||||
phone: '',
|
||||
can_manage_oauth_credentials: false,
|
||||
create_owner: false,
|
||||
owner_email: '',
|
||||
owner_name: '',
|
||||
owner_password: '',
|
||||
});
|
||||
setCreateError(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateSave = () => {
|
||||
setCreateError(null);
|
||||
|
||||
// Basic validation
|
||||
if (!createForm.name.trim()) {
|
||||
setCreateError('Business name is required');
|
||||
return;
|
||||
}
|
||||
if (!createForm.subdomain.trim()) {
|
||||
setCreateError('Subdomain is required');
|
||||
return;
|
||||
}
|
||||
if (createForm.create_owner) {
|
||||
if (!createForm.owner_email.trim()) {
|
||||
setCreateError('Owner email is required');
|
||||
return;
|
||||
}
|
||||
if (!createForm.owner_name.trim()) {
|
||||
setCreateError('Owner name is required');
|
||||
return;
|
||||
}
|
||||
if (!createForm.owner_password.trim()) {
|
||||
setCreateError('Owner password is required');
|
||||
return;
|
||||
}
|
||||
if (createForm.owner_password.length < 8) {
|
||||
setCreateError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
name: createForm.name,
|
||||
subdomain: createForm.subdomain,
|
||||
subscription_tier: createForm.subscription_tier,
|
||||
is_active: createForm.is_active,
|
||||
max_users: createForm.max_users,
|
||||
max_resources: createForm.max_resources,
|
||||
can_manage_oauth_credentials: createForm.can_manage_oauth_credentials,
|
||||
};
|
||||
|
||||
if (createForm.contact_email) {
|
||||
data.contact_email = createForm.contact_email;
|
||||
}
|
||||
if (createForm.phone) {
|
||||
data.phone = createForm.phone;
|
||||
}
|
||||
if (createForm.create_owner) {
|
||||
data.owner_email = createForm.owner_email;
|
||||
data.owner_name = createForm.owner_name;
|
||||
data.owner_password = createForm.owner_password;
|
||||
}
|
||||
|
||||
createBusinessMutation.mutate(data, {
|
||||
onSuccess: () => {
|
||||
handleClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error?.response?.data?.subdomain?.[0]
|
||||
|| error?.response?.data?.owner_email?.[0]
|
||||
|| error?.response?.data?.detail
|
||||
|| error?.message
|
||||
|| 'Failed to create business';
|
||||
setCreateError(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Building2 size={20} className="text-indigo-500" />
|
||||
Create New Business
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
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">
|
||||
{/* Error Message */}
|
||||
{createError && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Details Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Building2 size={16} className="text-indigo-500" />
|
||||
Business Details
|
||||
</h4>
|
||||
|
||||
{/* Business Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Business Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder="My Awesome Business"
|
||||
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>
|
||||
|
||||
{/* Subdomain */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subdomain <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.subdomain}
|
||||
onChange={(e) => setCreateForm({ ...createForm, subdomain: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') })}
|
||||
placeholder="mybusiness"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-l-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
<span className="px-3 py-2 bg-gray-100 dark:bg-gray-600 border border-l-0 border-gray-300 dark:border-gray-600 rounded-r-lg text-gray-500 dark:text-gray-400 text-sm">
|
||||
.lvh.me
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Only lowercase letters, numbers, and hyphens. Must start with a letter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<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">
|
||||
Contact Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createForm.contact_email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, contact_email: e.target.value })}
|
||||
placeholder="contact@business.com"
|
||||
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">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={createForm.phone}
|
||||
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
Create business as active
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateForm({ ...createForm, is_active: !createForm.is_active })}
|
||||
className={`${createForm.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={`${createForm.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={createForm.subscription_tier}
|
||||
onChange={(e) => setCreateForm({ ...createForm, 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={createForm.max_users}
|
||||
onChange={(e) => setCreateForm({ ...createForm, 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={createForm.max_resources}
|
||||
onChange={(e) => setCreateForm({ ...createForm, 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>
|
||||
</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={() => setCreateForm({ ...createForm, can_manage_oauth_credentials: !createForm.can_manage_oauth_credentials })}
|
||||
className={`${createForm.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={`${createForm.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>
|
||||
|
||||
{/* Owner Section */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<User size={16} className="text-blue-500" />
|
||||
Create Owner Account
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateForm({ ...createForm, create_owner: !createForm.create_owner })}
|
||||
className={`${createForm.create_owner ? 'bg-blue-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-blue-500`}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`${createForm.create_owner ? '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>
|
||||
|
||||
{createForm.create_owner && (
|
||||
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<Mail size={14} className="inline mr-1" />
|
||||
Owner Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createForm.owner_email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, owner_email: e.target.value })}
|
||||
placeholder="owner@business.com"
|
||||
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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<User size={14} className="inline mr-1" />
|
||||
Owner Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.owner_name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, owner_name: e.target.value })}
|
||||
placeholder="John Doe"
|
||||
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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<Lock size={14} className="inline mr-1" />
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.owner_password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, owner_password: e.target.value })}
|
||||
placeholder="Min. 8 characters"
|
||||
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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!createForm.create_owner && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
You can create an owner account later or invite one via email.
|
||||
</p>
|
||||
)}
|
||||
</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={handleClose}
|
||||
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={handleCreateSave}
|
||||
disabled={createBusinessMutation.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"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{createBusinessMutation.isPending ? 'Creating...' : 'Create Business'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessCreateModal;
|
||||
203
frontend/src/pages/platform/components/BusinessEditModal.tsx
Normal file
203
frontend/src/pages/platform/components/BusinessEditModal.tsx
Normal 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;
|
||||
518
frontend/src/pages/platform/components/TenantInviteModal.tsx
Normal file
518
frontend/src/pages/platform/components/TenantInviteModal.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Send, Mail, Building2 } from 'lucide-react';
|
||||
import { useCreateTenantInvitation } from '../../../hooks/usePlatform';
|
||||
|
||||
interface TenantInviteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }) => {
|
||||
const createInvitationMutation = useCreateTenantInvitation();
|
||||
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
suggested_business_name: '',
|
||||
subscription_tier: 'PROFESSIONAL' as 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE',
|
||||
custom_max_users: null as number | null,
|
||||
custom_max_resources: null as number | null,
|
||||
use_custom_limits: false,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: false,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
},
|
||||
// New feature limits (not yet implemented)
|
||||
limits: {
|
||||
can_add_video_conferencing: false,
|
||||
max_event_types: null as number | null, // null = unlimited
|
||||
max_calendars_connected: null as number | null, // null = unlimited
|
||||
can_connect_to_api: false,
|
||||
can_book_repeated_events: false,
|
||||
can_require_2fa: false,
|
||||
can_download_logs: false,
|
||||
can_delete_data: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
can_use_pos: false,
|
||||
can_use_mobile_app: false,
|
||||
},
|
||||
personal_message: '',
|
||||
});
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
const [inviteSuccess, setInviteSuccess] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setInviteForm({
|
||||
email: '',
|
||||
suggested_business_name: '',
|
||||
subscription_tier: 'PROFESSIONAL',
|
||||
custom_max_users: null,
|
||||
custom_max_resources: null,
|
||||
use_custom_limits: false,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: false,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
},
|
||||
limits: {
|
||||
can_add_video_conferencing: false,
|
||||
max_event_types: null,
|
||||
max_calendars_connected: null,
|
||||
can_connect_to_api: false,
|
||||
can_book_repeated_events: false,
|
||||
can_require_2fa: false,
|
||||
can_download_logs: false,
|
||||
can_delete_data: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
can_use_pos: false,
|
||||
can_use_mobile_app: false,
|
||||
},
|
||||
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 (inviteForm.use_custom_limits) {
|
||||
if (inviteForm.custom_max_users !== null && inviteForm.custom_max_users > 0) {
|
||||
data.custom_max_users = inviteForm.custom_max_users;
|
||||
}
|
||||
if (inviteForm.custom_max_resources !== null && inviteForm.custom_max_resources > 0) {
|
||||
data.custom_max_resources = inviteForm.custom_max_resources;
|
||||
}
|
||||
}
|
||||
|
||||
// Only include permissions if at least one is enabled
|
||||
const hasPermissions = Object.values(inviteForm.permissions).some(v => v === true);
|
||||
if (hasPermissions) {
|
||||
data.permissions = inviteForm.permissions;
|
||||
}
|
||||
|
||||
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) => setInviteForm({ ...inviteForm, subscription_tier: e.target.value as any })}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Custom Limits */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<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"
|
||||
/>
|
||||
Override tier limits with custom values
|
||||
</label>
|
||||
{inviteForm.use_custom_limits && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-2">
|
||||
<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.custom_max_users || ''}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, custom_max_users: e.target.value ? parseInt(e.target.value) : null })}
|
||||
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"
|
||||
placeholder="Leave empty for tier default"
|
||||
/>
|
||||
</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.custom_max_resources || ''}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, custom_max_resources: e.target.value ? parseInt(e.target.value) : null })}
|
||||
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"
|
||||
placeholder="Leave empty for tier default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform Permissions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Platform Permissions
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.permissions.can_manage_oauth_credentials}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, permissions: { ...inviteForm.permissions, can_manage_oauth_credentials: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can manage OAuth credentials
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.permissions.can_accept_payments}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, permissions: { ...inviteForm.permissions, can_accept_payments: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can accept online payments (Stripe Connect)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.permissions.can_use_custom_domain}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, permissions: { ...inviteForm.permissions, can_use_custom_domain: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can use custom domain
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.permissions.can_white_label}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, permissions: { ...inviteForm.permissions, can_white_label: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can remove SmoothSchedule branding
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.permissions.can_api_access}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, permissions: { ...inviteForm.permissions, can_api_access: e.target.checked } })}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Can access API for integrations
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Limits (Not Yet Implemented) */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Feature Limits & Capabilities
|
||||
</label>
|
||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 opacity-50">
|
||||
{/* Video Conferencing */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_add_video_conferencing}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can add video conferencing to events
|
||||
</label>
|
||||
|
||||
{/* Event Types Limit */}
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.max_event_types === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited event types</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
value={inviteForm.limits.max_event_types || ''}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendars Connected Limit */}
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.max_calendars_connected === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited calendar connections</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
value={inviteForm.limits.max_calendars_connected || ''}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Access */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_connect_to_api}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can connect to external APIs
|
||||
</label>
|
||||
|
||||
{/* Repeated Events */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_book_repeated_events}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can book repeated/recurring events
|
||||
</label>
|
||||
|
||||
{/* 2FA */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_require_2fa}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can require 2FA for users
|
||||
</label>
|
||||
|
||||
{/* Download Logs */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_download_logs}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can download system logs
|
||||
</label>
|
||||
|
||||
{/* Delete Data */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_delete_data}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can permanently delete data
|
||||
</label>
|
||||
|
||||
{/* Masked Phone Numbers */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_masked_phone_numbers}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can use masked phone numbers for privacy
|
||||
</label>
|
||||
|
||||
{/* POS Integration */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_pos}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can use Point of Sale (POS) system
|
||||
</label>
|
||||
|
||||
{/* Mobile App */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.limits.can_use_mobile_app}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can use mobile app
|
||||
</label>
|
||||
</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;
|
||||
Reference in New Issue
Block a user