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>
1299 lines
50 KiB
TypeScript
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;
|