Files
smoothschedule/legacy_reference/frontend/src/pages/platform/PlatformSettings.tsx
poduck 2e111364a2 Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 01:43:20 -05:00

1299 lines
50 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,
RefreshCw,
Layers,
Plus,
Pencil,
Trash2,
X,
DollarSign,
Check,
Lock,
Users,
ExternalLink,
} from 'lucide-react';
import {
usePlatformSettings,
useUpdateStripeKeys,
useValidateStripeKeys,
useSubscriptionPlans,
useCreateSubscriptionPlan,
useUpdateSubscriptionPlan,
useDeleteSubscriptionPlan,
useSyncPlansWithStripe,
SubscriptionPlan,
SubscriptionPlanCreate,
} from '../../hooks/usePlatformSettings';
import {
usePlatformOAuthSettings,
useUpdatePlatformOAuthSettings,
} from '../../hooks/usePlatformOAuth';
type TabType = 'stripe' | 'tiers' | 'oauth';
const PlatformSettings: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('stripe');
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
{ 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 === 'stripe' && <StripeSettingsTab />}
{activeTab === 'tiers' && <TiersSettingsTab />}
{activeTab === 'oauth' && <OAuthSettingsTab />}
</div>
);
};
const StripeSettingsTab: React.FC = () => {
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>Failed to load settings</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" />
Stripe Configuration Status
</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">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">Account ID:</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">Secret Key</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">Publishable Key</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">Webhook Secret</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 { data: plans, isLoading, error } = useSubscriptionPlans();
const createPlanMutation = useCreateSubscriptionPlan();
const updatePlanMutation = useUpdateSubscriptionPlan();
const deletePlanMutation = useDeleteSubscriptionPlan();
const syncMutation = useSyncPlansWithStripe();
const [showModal, setShowModal] = useState(false);
const [editingPlan, setEditingPlan] = 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 });
} else {
await createPlanMutation.mutateAsync(data);
}
setShowModal(false);
setEditingPlan(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">Base Tiers</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">Add-ons</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}
/>
)}
</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.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 || [],
transaction_fee_percent: plan?.transaction_fee_percent
? parseFloat(plan.transaction_fee_percent)
: 0,
transaction_fee_fixed: plan?.transaction_fee_fixed
? parseFloat(plan.transaction_fee_fixed)
: 0,
is_active: plan?.is_active ?? true,
is_public: plan?.is_public ?? 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 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-2xl 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-4">
<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">Base Tier</option>
<option value="addon">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 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="">None</option>
<option value="Free">Free</option>
<option value="Professional">Professional</option>
<option value="Business">Business</option>
<option value="Enterprise">Enterprise</option>
</select>
</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>
{/* Features */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Features
</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..."
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 gap-6">
<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>
{!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;