Files
smoothschedule/frontend/src/pages/platform/PlatformSettings.tsx
poduck 485f86086b feat: Unified FeaturesPermissionsEditor component for plan and business permissions
- Create reusable FeaturesPermissionsEditor component with support for both
  subscription plan editing and individual business permission overrides
- Add can_use_contracts field to Tenant model for per-business contracts toggle
- Update PlatformSettings.tsx to use unified component for plan permissions
- Update BusinessEditModal.tsx to use unified component for business permissions
- Update PlatformBusinessUpdate API interface with all permission fields
- Add contracts permission mapping to tenant sync task

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

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

1938 lines
81 KiB
TypeScript

/**
* Platform Settings Page
* Allows superusers to configure platform-wide settings including Stripe credentials and tiers
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Settings,
CreditCard,
Shield,
CheckCircle,
AlertCircle,
Loader2,
Eye,
EyeOff,
Layers,
Plus,
Pencil,
Trash2,
X,
DollarSign,
Check,
Lock,
Users,
ExternalLink,
Mail,
RefreshCw,
} from 'lucide-react';
import {
usePlatformSettings,
useUpdateStripeKeys,
useValidateStripeKeys,
useUpdateGeneralSettings,
useSubscriptionPlans,
useCreateSubscriptionPlan,
useUpdateSubscriptionPlan,
useDeleteSubscriptionPlan,
useSyncPlansWithStripe,
useSyncPlanToTenants,
SubscriptionPlan,
SubscriptionPlanCreate,
} from '../../hooks/usePlatformSettings';
import {
usePlatformOAuthSettings,
useUpdatePlatformOAuthSettings,
} from '../../hooks/usePlatformOAuth';
import { Link } from 'react-router-dom';
import FeaturesPermissionsEditor, { getPermissionKey, PERMISSION_DEFINITIONS } from '../../components/platform/FeaturesPermissionsEditor';
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
const PlatformSettings: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('general');
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
{ id: 'general', label: t('platform.settings.general', 'General'), icon: Settings },
{ id: 'stripe', label: 'Stripe', icon: CreditCard },
{ id: 'tiers', label: t('platform.settings.tiersPricing'), icon: Layers },
{ id: 'oauth', label: t('platform.settings.oauthProviders'), icon: Users },
];
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Settings className="w-7 h-7" />
{t('platform.settings.title')}
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
{t('platform.settings.description')}
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}
`}
>
<Icon className="w-5 h-5" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'general' && <GeneralSettingsTab />}
{activeTab === 'stripe' && <StripeSettingsTab />}
{activeTab === 'tiers' && <TiersSettingsTab />}
{activeTab === 'oauth' && <OAuthSettingsTab />}
</div>
);
};
const GeneralSettingsTab: React.FC = () => {
const { t } = useTranslation();
const { data: settings, isLoading } = usePlatformSettings();
const updateGeneralSettings = useUpdateGeneralSettings();
const [emailCheckInterval, setEmailCheckInterval] = useState(5);
const [hasChanges, setHasChanges] = useState(false);
// Sync local state with settings when loaded
React.useEffect(() => {
if (settings?.email_check_interval_minutes) {
setEmailCheckInterval(settings.email_check_interval_minutes);
}
}, [settings]);
const handleIntervalChange = (value: number) => {
setEmailCheckInterval(value);
setHasChanges(value !== settings?.email_check_interval_minutes);
};
const handleSaveInterval = async () => {
await updateGeneralSettings.mutateAsync({ email_check_interval_minutes: emailCheckInterval });
setHasChanges(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
return (
<div className="space-y-6">
{/* Email Settings Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Mail className="w-5 h-5" />
{t('platform.settings.emailAddresses', 'Platform Email Addresses')}
</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Platform email addresses are now managed on a dedicated page with direct integration
to the mail server.
</p>
</div>
<Link
to="/platform/email-addresses"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Mail className="w-4 h-4" />
Manage Email Addresses
</Link>
</div>
</div>
{/* Email Check Interval */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
<Mail className="w-5 h-5" />
Email Polling Settings
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Check for new emails every
</label>
<div className="flex items-center gap-3">
<select
value={emailCheckInterval}
onChange={(e) => handleIntervalChange(parseInt(e.target.value))}
className="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-transparent"
>
<option value={1}>1 minute</option>
<option value={2}>2 minutes</option>
<option value={5}>5 minutes</option>
<option value={10}>10 minutes</option>
<option value={15}>15 minutes</option>
<option value={30}>30 minutes</option>
<option value={60}>60 minutes</option>
</select>
{hasChanges && (
<button
onClick={handleSaveInterval}
disabled={updateGeneralSettings.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{updateGeneralSettings.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
Save
</button>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
This controls how often the system checks for incoming emails to create support tickets.
</p>
</div>
</div>
{updateGeneralSettings.isSuccess && (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Email polling interval updated
</p>
</div>
)}
</div>
{/* Platform Info */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
<Settings className="w-5 h-5" />
{t('platform.settings.platformInfo', 'Platform Information')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('platform.settings.mailServer')}</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">mail.talova.net</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('platform.settings.emailDomain')}</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">smoothschedule.com</p>
</div>
</div>
</div>
</div>
);
};
const StripeSettingsTab: React.FC = () => {
const { t } = useTranslation();
const { data: settings, isLoading, error } = usePlatformSettings();
const updateKeysMutation = useUpdateStripeKeys();
const validateKeysMutation = useValidateStripeKeys();
const [secretKey, setSecretKey] = useState('');
const [publishableKey, setPublishableKey] = useState('');
const [webhookSecret, setWebhookSecret] = useState('');
const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const handleSaveKeys = async () => {
const keys: Record<string, string> = {};
if (secretKey) keys.stripe_secret_key = secretKey;
if (publishableKey) keys.stripe_publishable_key = publishableKey;
if (webhookSecret) keys.stripe_webhook_secret = webhookSecret;
if (Object.keys(keys).length === 0) return;
await updateKeysMutation.mutateAsync(keys);
setSecretKey('');
setPublishableKey('');
setWebhookSecret('');
};
const handleValidate = async () => {
await validateKeysMutation.mutateAsync();
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertCircle className="w-5 h-5" />
<span>{t('platform.settings.failedToLoadSettings')}</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
{t('platform.settings.stripeConfigStatus')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{settings?.has_stripe_keys ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">API Keys</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{settings?.has_stripe_keys ? 'Configured' : 'Not configured'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{settings?.stripe_keys_validated_at ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('platform.settings.validation')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{settings?.stripe_keys_validated_at
? `Validated ${new Date(settings.stripe_keys_validated_at).toLocaleDateString()}`
: 'Not validated'}
</p>
</div>
</div>
</div>
{settings?.stripe_account_id && (
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-400">
<span className="font-medium">{t('platform.settings.accountId')}:</span> {settings.stripe_account_id}
{settings.stripe_account_name && (
<span className="ml-2">({settings.stripe_account_name})</span>
)}
</p>
</div>
)}
{settings?.stripe_validation_error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
<span className="font-medium">Error:</span> {settings.stripe_validation_error}
</p>
</div>
)}
</div>
{/* Current Keys Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Current API Keys
</h2>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">{t('platform.settings.secretKey')}</span>
<code className="text-sm font-mono text-gray-900 dark:text-white">
{settings?.stripe_secret_key_masked || 'Not configured'}
</code>
</div>
<div className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">{t('platform.settings.publishableKey')}</span>
<code className="text-sm font-mono text-gray-900 dark:text-white">
{settings?.stripe_publishable_key_masked || 'Not configured'}
</code>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-sm text-gray-600 dark:text-gray-400">{t('platform.settings.webhookSecret')}</span>
<code className="text-sm font-mono text-gray-900 dark:text-white">
{settings?.stripe_webhook_secret_masked || 'Not configured'}
</code>
</div>
</div>
{settings?.has_stripe_keys && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleValidate}
disabled={validateKeysMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors disabled:opacity-50"
>
{validateKeysMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Validate Keys
</button>
</div>
)}
</div>
{/* Update Keys Form - Only show if keys are NOT from environment variables */}
{settings?.stripe_keys_from_env ? (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 p-6">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h3 className="font-medium text-blue-800 dark:text-blue-300">
Stripe Keys Configured via Environment Variables
</h3>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
Your Stripe API keys are configured through environment variables (.env file).
To update them, modify your environment configuration and restart the server.
</p>
<p className="text-xs text-blue-600 dark:text-blue-500 mt-2">
Environment variables take priority over database-stored keys for security.
</p>
</div>
</div>
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Update API Keys
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Enter new keys to update. Leave fields empty to keep existing values.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Secret Key
</label>
<div className="relative">
<input
type={showSecretKey ? 'text' : 'password'}
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder="sk_live_... or sk_test_..."
className="w-full px-4 py-2 pr-10 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-transparent"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showSecretKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Publishable Key
</label>
<input
type="text"
value={publishableKey}
onChange={(e) => setPublishableKey(e.target.value)}
placeholder="pk_live_... or pk_test_..."
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webhook Secret
</label>
<div className="relative">
<input
type={showWebhookSecret ? 'text' : 'password'}
value={webhookSecret}
onChange={(e) => setWebhookSecret(e.target.value)}
placeholder="whsec_..."
className="w-full px-4 py-2 pr-10 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-transparent"
/>
<button
type="button"
onClick={() => setShowWebhookSecret(!showWebhookSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showWebhookSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="pt-2">
<button
onClick={handleSaveKeys}
disabled={
updateKeysMutation.isPending || (!secretKey && !publishableKey && !webhookSecret)
}
className="inline-flex items-center gap-2 px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{updateKeysMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
'Save Keys'
)}
</button>
</div>
</div>
{updateKeysMutation.isError && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
Failed to update keys. Please check the format and try again.
</p>
</div>
)}
{updateKeysMutation.isSuccess && (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Keys updated successfully
</p>
</div>
)}
</div>
)}
</div>
);
};
const TiersSettingsTab: React.FC = () => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = useSubscriptionPlans();
const createPlanMutation = useCreateSubscriptionPlan();
const updatePlanMutation = useUpdateSubscriptionPlan();
const deletePlanMutation = useDeleteSubscriptionPlan();
const syncMutation = useSyncPlansWithStripe();
const syncTenantsMutation = useSyncPlanToTenants();
const [showModal, setShowModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
const [showSyncConfirmModal, setShowSyncConfirmModal] = useState(false);
const [savedPlanForSync, setSavedPlanForSync] = useState<SubscriptionPlan | null>(null);
const handleCreatePlan = () => {
setEditingPlan(null);
setShowModal(true);
};
const handleEditPlan = (plan: SubscriptionPlan) => {
setEditingPlan(plan);
setShowModal(true);
};
const handleDeletePlan = async (plan: SubscriptionPlan) => {
if (confirm(`Are you sure you want to deactivate the "${plan.name}" plan?`)) {
await deletePlanMutation.mutateAsync(plan.id);
}
};
const handleSavePlan = async (data: SubscriptionPlanCreate) => {
if (editingPlan) {
await updatePlanMutation.mutateAsync({ id: editingPlan.id, ...data });
// After updating an existing plan, ask if they want to sync to tenants
setSavedPlanForSync(editingPlan);
setShowSyncConfirmModal(true);
} else {
await createPlanMutation.mutateAsync(data);
}
setShowModal(false);
setEditingPlan(null);
};
const handleSyncConfirm = async () => {
if (savedPlanForSync) {
await syncTenantsMutation.mutateAsync(savedPlanForSync.id);
}
setShowSyncConfirmModal(false);
setSavedPlanForSync(null);
};
const handleSyncCancel = () => {
setShowSyncConfirmModal(false);
setSavedPlanForSync(null);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertCircle className="w-5 h-5" />
<span>Failed to load subscription plans</span>
</div>
</div>
);
}
const basePlans = plans?.filter((p) => p.plan_type === 'base') || [];
const addonPlans = plans?.filter((p) => p.plan_type === 'addon') || [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Subscription Plans
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Configure pricing tiers and add-ons for businesses
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{syncMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Sync with Stripe
</button>
<button
onClick={handleCreatePlan}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Add Plan
</button>
</div>
</div>
{/* Base Plans */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-gray-900 dark:text-white">{t('platform.settings.baseTiers')}</h3>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{basePlans.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
No base tiers configured. Click "Add Plan" to create one.
</div>
) : (
basePlans.map((plan) => (
<PlanRow
key={plan.id}
plan={plan}
onEdit={() => handleEditPlan(plan)}
onDelete={() => handleDeletePlan(plan)}
/>
))
)}
</div>
</div>
{/* Add-on Plans */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-gray-900 dark:text-white">{t('platform.settings.addOns')}</h3>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{addonPlans.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
No add-ons configured.
</div>
) : (
addonPlans.map((plan) => (
<PlanRow
key={plan.id}
plan={plan}
onEdit={() => handleEditPlan(plan)}
onDelete={() => handleDeletePlan(plan)}
/>
))
)}
</div>
</div>
{/* Plan Modal */}
{showModal && (
<PlanModal
plan={editingPlan}
onSave={handleSavePlan}
onClose={() => {
setShowModal(false);
setEditingPlan(null);
}}
isLoading={createPlanMutation.isPending || updatePlanMutation.isPending}
/>
)}
{/* Sync Confirmation Modal */}
{showSyncConfirmModal && savedPlanForSync && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Update All Tenants?
</h3>
<p className="text-gray-600 dark:text-gray-400">
Do you want to sync the updated settings to all tenants currently on the "{savedPlanForSync.name}" plan?
</p>
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2">
This will update permissions and limits for all businesses on this tier.
</p>
</div>
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-700/50 rounded-b-lg">
<button
onClick={handleSyncCancel}
disabled={syncTenantsMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
No, Skip
</button>
<button
onClick={handleSyncConfirm}
disabled={syncTenantsMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{syncTenantsMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Syncing...
</>
) : (
'Yes, Update All Tenants'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};
interface PlanRowProps {
plan: SubscriptionPlan;
onEdit: () => void;
onDelete: () => void;
}
const PlanRow: React.FC<PlanRowProps> = ({ plan, onEdit, onDelete }) => {
return (
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h4 className="font-medium text-gray-900 dark:text-white">{plan.name}</h4>
{!plan.is_active && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
Inactive
</span>
)}
{!plan.is_public && plan.is_active && (
<span className="px-2 py-0.5 text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded">
Hidden
</span>
)}
{plan.is_most_popular && (
<span className="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">
Popular
</span>
)}
{!plan.show_price && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
Price Hidden
</span>
)}
{plan.business_tier && (
<span className="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">
{plan.business_tier}
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{plan.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
{plan.price_monthly && (
<span className="flex items-center gap-1 text-gray-600 dark:text-gray-400">
<DollarSign className="w-4 h-4" />
{parseFloat(plan.price_monthly).toFixed(2)}/mo
</span>
)}
{plan.features.length > 0 && (
<span className="text-gray-500 dark:text-gray-400">
{plan.features.length} features
</span>
)}
{parseFloat(plan.transaction_fee_percent) > 0 && (
<span className="text-gray-500 dark:text-gray-400">
{plan.transaction_fee_percent}% fee
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={onDelete}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
};
interface PlanModalProps {
plan: SubscriptionPlan | null;
onSave: (data: SubscriptionPlanCreate) => void;
onClose: () => void;
isLoading: boolean;
}
const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading }) => {
const [formData, setFormData] = useState<SubscriptionPlanCreate>({
name: plan?.name || '',
description: plan?.description || '',
plan_type: plan?.plan_type || 'base',
price_monthly: plan?.price_monthly ? parseFloat(plan.price_monthly) : undefined,
price_yearly: plan?.price_yearly ? parseFloat(plan.price_yearly) : undefined,
business_tier: plan?.business_tier || '',
features: plan?.features || [],
limits: plan?.limits || {
max_users: 5,
max_resources: 10,
max_appointments: 100,
max_automated_tasks: 5,
},
permissions: plan?.permissions || {
can_accept_payments: false,
sms_reminders: false,
advanced_reporting: false,
priority_support: false,
can_use_custom_domain: false,
can_use_plugins: false,
can_use_tasks: false,
can_create_plugins: false,
can_white_label: false,
can_api_access: false,
can_use_masked_phone_numbers: false,
},
// Default transaction fees: Stripe charges 2.9% + $0.30, so we need to charge more
// Recommended: FREE 5%+50¢, STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢
transaction_fee_percent: plan?.transaction_fee_percent
? parseFloat(plan.transaction_fee_percent)
: 4.0, // Default 4% for new plans
transaction_fee_fixed: plan?.transaction_fee_fixed
? parseFloat(plan.transaction_fee_fixed)
: 40, // Default 40 cents for new plans
// Communication pricing
sms_enabled: plan?.sms_enabled ?? false,
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
masked_calling_enabled: plan?.masked_calling_enabled ?? false,
masked_calling_price_per_minute_cents: plan?.masked_calling_price_per_minute_cents ?? 5,
proxy_number_enabled: plan?.proxy_number_enabled ?? false,
proxy_number_monthly_fee_cents: plan?.proxy_number_monthly_fee_cents ?? 200,
// Contracts feature
contracts_enabled: plan?.contracts_enabled ?? false,
// Default credit settings
default_auto_reload_enabled: plan?.default_auto_reload_enabled ?? false,
default_auto_reload_threshold_cents: plan?.default_auto_reload_threshold_cents ?? 1000,
default_auto_reload_amount_cents: plan?.default_auto_reload_amount_cents ?? 2500,
is_active: plan?.is_active ?? true,
is_public: plan?.is_public ?? true,
is_most_popular: plan?.is_most_popular ?? false,
show_price: plan?.show_price ?? true,
create_stripe_product: false,
stripe_product_id: plan?.stripe_product_id || '',
stripe_price_id: plan?.stripe_price_id || '',
});
const [newFeature, setNewFeature] = useState('');
const handleAddFeature = () => {
if (newFeature.trim()) {
setFormData((prev) => ({
...prev,
features: [...(prev.features || []), newFeature.trim()],
}));
setNewFeature('');
}
};
const handleRemoveFeature = (index: number) => {
setFormData((prev) => ({
...prev,
features: prev.features?.filter((_, i) => i !== index) || [],
}));
};
const handleLimitChange = (key: string, value: string) => {
setFormData((prev) => ({
...prev,
limits: {
...prev.limits,
[key]: parseInt(value) || 0,
},
}));
};
const handlePermissionChange = (key: string, value: boolean) => {
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
[key]: value,
},
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{plan ? 'Edit Plan' : 'Create Plan'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Basic Information
</h3>
<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">
Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required
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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plan Type
</label>
<select
value={formData.plan_type}
onChange={(e) =>
setFormData((prev) => ({
...prev,
plan_type: e.target.value as 'base' | 'addon',
}))
}
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"
>
<option value="base" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Base Tier</option>
<option value="addon" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Add-on</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
rows={2}
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>
{/* Pricing */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Pricing & Fees
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Price ($)
</label>
<input
type="number"
step="0.01"
value={formData.price_monthly || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price_monthly: e.target.value ? parseFloat(e.target.value) : undefined,
}))
}
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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Yearly Price ($)
</label>
<input
type="number"
step="0.01"
value={formData.price_yearly || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price_yearly: e.target.value ? parseFloat(e.target.value) : undefined,
}))
}
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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Business Tier
</label>
<select
value={formData.business_tier}
onChange={(e) => setFormData((prev) => ({ ...prev, business_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"
>
<option value="" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">None (Add-on)</option>
<option value="Free" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Free</option>
<option value="Starter" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Starter</option>
<option value="Professional" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Professional</option>
<option value="Business" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Business</option>
<option value="Enterprise" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Enterprise</option>
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trial Days
</label>
<input
type="number"
min="0"
value={formData.limits?.trial_days ?? 0}
onChange={(e) => setFormData((prev) => ({
...prev,
limits: { ...prev.limits, trial_days: parseInt(e.target.value) || 0 }
}))}
placeholder="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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Days of free trial</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Display Order
</label>
<input
type="number"
min="0"
value={formData.limits?.display_order ?? 0}
onChange={(e) => setFormData((prev) => ({
...prev,
limits: { ...prev.limits, display_order: parseInt(e.target.value) || 0 }
}))}
placeholder="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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Order on pricing page</p>
</div>
</div>
<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">
Transaction Fee (%)
</label>
<input
type="number"
step="0.01"
value={formData.transaction_fee_percent || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
transaction_fee_percent: parseFloat(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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Fixed Fee (cents)
</label>
<input
type="number"
step="1"
value={formData.transaction_fee_fixed || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
transaction_fee_fixed: parseFloat(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>
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
Stripe charges 2.9% + $0.30 per transaction + $2/mo per connected account. Recommended: STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢. Consider a payments add-on ($5/mo) for FREE tier users.
</p>
</div>
{/* Communication Pricing */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Communication Pricing
</h3>
{/* SMS Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">SMS Reminders</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Allow businesses on this tier to send SMS reminders</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.sms_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, sms_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
{formData.sms_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Price per SMS (cents)
</label>
<input
type="number"
min="0"
step="1"
value={formData.sms_price_per_message_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
sms_price_per_message_cents: 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Current: ${((formData.sms_price_per_message_cents || 0) / 100).toFixed(2)} per message
</p>
</div>
)}
</div>
{/* Masked Calling Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Masked Calling</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Allow anonymous calls between customers and staff</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.masked_calling_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, masked_calling_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
{formData.masked_calling_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Price per minute (cents)
</label>
<input
type="number"
min="0"
step="1"
value={formData.masked_calling_price_per_minute_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
masked_calling_price_per_minute_cents: 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Current: ${((formData.masked_calling_price_per_minute_cents || 0) / 100).toFixed(2)} per minute
</p>
</div>
)}
</div>
{/* Proxy Phone Number Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Proxy Phone Numbers</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Dedicated phone numbers for masked communication</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.proxy_number_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, proxy_number_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
{formData.proxy_number_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly fee per number (cents)
</label>
<input
type="number"
min="0"
step="1"
value={formData.proxy_number_monthly_fee_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
proxy_number_monthly_fee_cents: 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Current: ${((formData.proxy_number_monthly_fee_cents || 0) / 100).toFixed(2)} per month
</p>
</div>
)}
</div>
{/* Default Credit Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
Default auto-reload settings for new businesses on this tier
</p>
<div className="space-y-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.default_auto_reload_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, default_auto_reload_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Auto-reload enabled by default</span>
</label>
<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">
Reload threshold (cents)
</label>
<input
type="number"
min="0"
step="100"
value={formData.default_auto_reload_threshold_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
default_auto_reload_threshold_cents: 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Reload when balance falls below ${((formData.default_auto_reload_threshold_cents || 0) / 100).toFixed(2)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reload amount (cents)
</label>
<input
type="number"
min="0"
step="100"
value={formData.default_auto_reload_amount_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
default_auto_reload_amount_cents: 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Add ${((formData.default_auto_reload_amount_cents || 0) / 100).toFixed(2)} to balance
</p>
</div>
</div>
</div>
</div>
</div>
{/* Limits Configuration */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Limits Configuration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Use -1 for unlimited. These limits control what businesses on this plan can create.
</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Additional Users
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_users ?? 0}
onChange={(e) => handleLimitChange('max_users', 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"
/>
</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={formData.limits?.max_resources ?? 0}
onChange={(e) => handleLimitChange('max_resources', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Services
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_services ?? 0}
onChange={(e) => handleLimitChange('max_services', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Appointments / Month
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_appointments ?? 0}
onChange={(e) => handleLimitChange('max_appointments', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Email Templates
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_email_templates ?? 0}
onChange={(e) => handleLimitChange('max_email_templates', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Automated Tasks
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_automated_tasks ?? 0}
onChange={(e) => handleLimitChange('max_automated_tasks', 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"
/>
</div>
</div>
</div>
{/* Permissions Configuration - Using unified FeaturesPermissionsEditor */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<FeaturesPermissionsEditor
mode="plan"
values={{
...formData.permissions,
// Map contracts_enabled to the permission key used by the component
contracts_enabled: formData.contracts_enabled || false,
}}
onChange={(key, value) => {
// Handle contracts_enabled specially since it's a top-level plan field
if (key === 'contracts_enabled') {
setFormData((prev) => ({ ...prev, contracts_enabled: value }));
} else {
handlePermissionChange(key, value);
}
}}
headerTitle="Features & Permissions"
/>
</div>
{/* Display Features (List of strings) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Marketing Features List (Display Only)
</label>
<div className="space-y-2">
{formData.features?.map((feature, index) => (
<div key={index} className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-500" />
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300">{feature}</span>
<button
type="button"
onClick={() => handleRemoveFeature(index)}
className="text-gray-400 hover:text-red-500"
>
<X className="w-4 h-4" />
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newFeature}
onChange={(e) => setNewFeature(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddFeature())}
placeholder="Add a feature description..."
className="flex-1 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 text-sm"
/>
<button
type="button"
onClick={handleAddFeature}
className="px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
>
Add
</button>
</div>
</div>
</div>
{/* Status toggles */}
<div className="flex flex-wrap gap-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData((prev) => ({ ...prev, is_active: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) => setFormData((prev) => ({ ...prev, is_public: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Public</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_most_popular}
onChange={(e) => setFormData((prev) => ({ ...prev, is_most_popular: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Most Popular</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.show_price}
onChange={(e) => setFormData((prev) => ({ ...prev, show_price: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Show Price on Marketing</span>
</label>
{!plan && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.create_stripe_product}
onChange={(e) =>
setFormData((prev) => ({ ...prev, create_stripe_product: e.target.checked }))
}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Create Stripe Product
</span>
</label>
)}
</div>
{/* Stripe IDs (for manual entry) */}
{!formData.create_stripe_product && (
<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">
Stripe Product ID
</label>
<input
type="text"
value={formData.stripe_product_id || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, stripe_product_id: e.target.value }))
}
placeholder="prod_..."
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Stripe Price ID
</label>
<input
type="text"
value={formData.stripe_price_id || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, stripe_price_id: e.target.value }))
}
placeholder="price_..."
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 text-sm"
/>
</div>
</div>
)}
{/* Submit */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !formData.name}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
'Save Plan'
)}
</button>
</div>
</form>
</div>
</div>
);
};
const OAuthSettingsTab: React.FC = () => {
const { data: settings, isLoading, error } = usePlatformOAuthSettings();
const updateMutation = useUpdatePlatformOAuthSettings();
const [allowRegistration, setAllowRegistration] = useState(false);
// Provider states
const [providers, setProviders] = useState<{
[key: string]: {
enabled: boolean;
client_id: string;
client_secret: string;
team_id?: string;
key_id?: string;
tenant_id?: string;
};
}>({});
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
// Update local state when settings load
React.useEffect(() => {
if (settings) {
setAllowRegistration(settings.oauth_allow_registration);
setProviders({
google: settings.google,
apple: settings.apple,
facebook: settings.facebook,
linkedin: settings.linkedin,
microsoft: settings.microsoft,
twitter: settings.twitter,
twitch: settings.twitch,
});
}
}, [settings]);
const handleSave = async () => {
const updateData: any = {
oauth_allow_registration: allowRegistration,
};
// Add all provider settings
Object.entries(providers).forEach(([provider, config]) => {
const prefix = `oauth_${provider}`;
updateData[`${prefix}_enabled`] = config.enabled;
updateData[`${prefix}_client_id`] = config.client_id;
updateData[`${prefix}_client_secret`] = config.client_secret;
// Apple-specific
if (provider === 'apple') {
updateData[`${prefix}_team_id`] = config.team_id || '';
updateData[`${prefix}_key_id`] = config.key_id || '';
}
// Microsoft-specific
if (provider === 'microsoft') {
updateData[`${prefix}_tenant_id`] = config.tenant_id || '';
}
});
await updateMutation.mutateAsync(updateData);
};
const updateProvider = (provider: string, field: string, value: any) => {
setProviders((prev) => ({
...prev,
[provider]: {
...prev[provider],
[field]: value,
},
}));
};
const toggleShowSecret = (key: string) => {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertCircle className="w-5 h-5" />
<span>Failed to load OAuth settings</span>
</div>
</div>
);
}
const providerList = [
{
key: 'google',
name: 'Google',
hasExtra: false,
docsUrl: 'https://console.cloud.google.com/apis/credentials',
docsLabel: 'Google Cloud Console',
},
{
key: 'apple',
name: 'Apple',
hasExtra: true,
extraFields: ['team_id', 'key_id'],
docsUrl: 'https://developer.apple.com/account/resources/identifiers/list/serviceId',
docsLabel: 'Apple Developer Portal',
},
{
key: 'facebook',
name: 'Facebook',
hasExtra: false,
docsUrl: 'https://developers.facebook.com/apps/',
docsLabel: 'Meta for Developers',
},
{
key: 'linkedin',
name: 'LinkedIn',
hasExtra: false,
docsUrl: 'https://www.linkedin.com/developers/apps',
docsLabel: 'LinkedIn Developer Portal',
},
{
key: 'microsoft',
name: 'Microsoft',
hasExtra: true,
extraFields: ['tenant_id'],
docsUrl: 'https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
docsLabel: 'Azure App Registrations',
},
{
key: 'twitter',
name: 'X (Twitter)',
hasExtra: false,
docsUrl: 'https://developer.twitter.com/en/portal/dashboard',
docsLabel: 'X Developer Portal',
},
{
key: 'twitch',
name: 'Twitch',
hasExtra: false,
docsUrl: 'https://dev.twitch.tv/console/apps',
docsLabel: 'Twitch Developer Console',
},
];
return (
<div className="space-y-6">
{/* Global Settings */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
Global OAuth Settings
</h2>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={allowRegistration}
onChange={(e) => setAllowRegistration(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
Allow OAuth Registration
</span>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enable new users to register via OAuth providers on the platform
</p>
</div>
</label>
</div>
{/* OAuth Providers */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Users className="w-5 h-5" />
OAuth Providers
</h2>
<div className="space-y-6">
{providerList.map((provider) => (
<div
key={provider.key}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{provider.name}</h3>
<a
href={provider.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1"
>
<ExternalLink className="w-3 h-3" />
{provider.docsLabel}
</a>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={providers[provider.key]?.enabled || false}
onChange={(e) => updateProvider(provider.key, 'enabled', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
<div className="space-y-3">
{/* Client ID */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client ID
</label>
<input
type="text"
value={providers[provider.key]?.client_id || ''}
onChange={(e) => updateProvider(provider.key, 'client_id', e.target.value)}
placeholder="Enter client ID"
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-transparent text-sm"
/>
</div>
{/* Client Secret */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client Secret
</label>
<div className="relative">
<input
type={showSecrets[`${provider.key}_secret`] ? 'text' : 'password'}
value={providers[provider.key]?.client_secret || ''}
onChange={(e) =>
updateProvider(provider.key, 'client_secret', e.target.value)
}
placeholder="Enter client secret"
className="w-full px-3 py-2 pr-10 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-transparent text-sm"
/>
<button
type="button"
onClick={() => toggleShowSecret(`${provider.key}_secret`)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showSecrets[`${provider.key}_secret`] ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Apple-specific fields */}
{provider.key === 'apple' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Team ID
</label>
<input
type="text"
value={providers[provider.key]?.team_id || ''}
onChange={(e) => updateProvider(provider.key, 'team_id', e.target.value)}
placeholder="Enter Apple Team ID"
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-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Key ID
</label>
<input
type="text"
value={providers[provider.key]?.key_id || ''}
onChange={(e) => updateProvider(provider.key, 'key_id', e.target.value)}
placeholder="Enter Apple Key ID"
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-transparent text-sm"
/>
</div>
</>
)}
{/* Microsoft-specific field */}
{provider.key === 'microsoft' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tenant ID <span className="text-gray-400">(optional, default: common)</span>
</label>
<input
type="text"
value={providers[provider.key]?.tenant_id || ''}
onChange={(e) => updateProvider(provider.key, 'tenant_id', e.target.value)}
placeholder="common"
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-transparent text-sm"
/>
</div>
)}
</div>
</div>
))}
</div>
{/* Save Button */}
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className="inline-flex items-center gap-2 px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
<Lock className="w-4 h-4" />
Save OAuth Settings
</>
)}
</button>
</div>
{updateMutation.isError && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
Failed to update OAuth settings. Please try again.
</p>
</div>
)}
{updateMutation.isSuccess && (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
OAuth settings updated successfully
</p>
</div>
)}
</div>
</div>
);
};
export default PlatformSettings;