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>
442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
/**
|
|
* Stripe API Keys Form Component
|
|
* For free-tier businesses to enter and manage their Stripe API keys
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
Key,
|
|
Eye,
|
|
EyeOff,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Loader2,
|
|
Trash2,
|
|
RefreshCw,
|
|
FlaskConical,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
import { ApiKeysInfo } from '../api/payments';
|
|
import {
|
|
useValidateApiKeys,
|
|
useSaveApiKeys,
|
|
useDeleteApiKeys,
|
|
useRevalidateApiKeys,
|
|
} from '../hooks/usePayments';
|
|
|
|
interface StripeApiKeysFormProps {
|
|
apiKeys: ApiKeysInfo | null;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
|
|
const [secretKey, setSecretKey] = useState('');
|
|
const [publishableKey, setPublishableKey] = useState('');
|
|
const [showSecretKey, setShowSecretKey] = useState(false);
|
|
const [validationResult, setValidationResult] = useState<{
|
|
valid: boolean;
|
|
accountName?: string;
|
|
environment?: string;
|
|
error?: string;
|
|
} | null>(null);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
|
|
const validateMutation = useValidateApiKeys();
|
|
const saveMutation = useSaveApiKeys();
|
|
const deleteMutation = useDeleteApiKeys();
|
|
const revalidateMutation = useRevalidateApiKeys();
|
|
|
|
const isConfigured = apiKeys && apiKeys.status !== 'deprecated';
|
|
const isDeprecated = apiKeys?.status === 'deprecated';
|
|
const isInvalid = apiKeys?.status === 'invalid';
|
|
|
|
// Determine if using test or live keys from the masked key prefix
|
|
const getKeyEnvironment = (maskedKey: string | undefined): 'test' | 'live' | null => {
|
|
if (!maskedKey) return null;
|
|
if (maskedKey.startsWith('pk_test_') || maskedKey.startsWith('sk_test_')) return 'test';
|
|
if (maskedKey.startsWith('pk_live_') || maskedKey.startsWith('sk_live_')) return 'live';
|
|
return null;
|
|
};
|
|
const keyEnvironment = getKeyEnvironment(apiKeys?.publishable_key_masked);
|
|
|
|
const handleValidate = async () => {
|
|
setValidationResult(null);
|
|
try {
|
|
const result = await validateMutation.mutateAsync({ secretKey, publishableKey });
|
|
setValidationResult({
|
|
valid: result.valid,
|
|
accountName: result.account_name,
|
|
environment: result.environment,
|
|
error: result.error,
|
|
});
|
|
} catch (error: any) {
|
|
setValidationResult({
|
|
valid: false,
|
|
error: error.response?.data?.error || 'Validation failed',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
await saveMutation.mutateAsync({ secretKey, publishableKey });
|
|
setSecretKey('');
|
|
setPublishableKey('');
|
|
setValidationResult(null);
|
|
onSuccess?.();
|
|
} catch (error: any) {
|
|
setValidationResult({
|
|
valid: false,
|
|
error: error.response?.data?.error || 'Failed to save keys',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
await deleteMutation.mutateAsync();
|
|
setShowDeleteConfirm(false);
|
|
onSuccess?.();
|
|
} catch (error) {
|
|
console.error('Failed to delete keys:', error);
|
|
}
|
|
};
|
|
|
|
const handleRevalidate = async () => {
|
|
try {
|
|
await revalidateMutation.mutateAsync();
|
|
onSuccess?.();
|
|
} catch (error) {
|
|
console.error('Failed to revalidate keys:', error);
|
|
}
|
|
};
|
|
|
|
const canSave = validationResult?.valid && secretKey && publishableKey;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Current Configuration */}
|
|
{isConfigured && (
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
|
<CheckCircle size={18} className="text-green-500" />
|
|
Stripe Keys Configured
|
|
</h4>
|
|
<div className="flex items-center gap-2">
|
|
{/* Environment Badge */}
|
|
{keyEnvironment && (
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
|
|
keyEnvironment === 'test'
|
|
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
|
|
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
|
}`}
|
|
>
|
|
{keyEnvironment === 'test' ? (
|
|
<>
|
|
<FlaskConical size={12} />
|
|
Test Mode
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap size={12} />
|
|
Live Mode
|
|
</>
|
|
)}
|
|
</span>
|
|
)}
|
|
{/* Status Badge */}
|
|
<span
|
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
apiKeys.status === 'active'
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
|
: apiKeys.status === 'invalid'
|
|
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
|
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
|
}`}
|
|
>
|
|
{apiKeys.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
|
|
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Secret Key:</span>
|
|
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
|
|
</div>
|
|
{apiKeys.stripe_account_name && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Account:</span>
|
|
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
|
|
</div>
|
|
)}
|
|
{apiKeys.last_validated_at && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Last Validated:</span>
|
|
<span className="text-gray-900 dark:text-white">
|
|
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Test Mode Warning */}
|
|
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
|
|
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
|
|
<FlaskConical size={16} className="shrink-0 mt-0.5" />
|
|
<span>
|
|
You are using <strong>test keys</strong>. Payments will not be processed for real.
|
|
Switch to live keys when ready to accept real payments.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{isInvalid && apiKeys.validation_error && (
|
|
<div className="mt-3 p-2 bg-red-50 rounded text-sm text-red-700">
|
|
{apiKeys.validation_error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 mt-4">
|
|
<button
|
|
onClick={handleRevalidate}
|
|
disabled={revalidateMutation.isPending}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
{revalidateMutation.isPending ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<RefreshCw size={16} />
|
|
)}
|
|
Re-validate
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50"
|
|
>
|
|
<Trash2 size={16} />
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deprecated Notice */}
|
|
{isDeprecated && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
|
<div>
|
|
<h4 className="font-medium text-yellow-800">API Keys Deprecated</h4>
|
|
<p className="text-sm text-yellow-700 mt-1">
|
|
Your API keys have been deprecated because you upgraded to a paid tier.
|
|
Please complete Stripe Connect onboarding to accept payments.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add/Update Keys Form */}
|
|
{(!isConfigured || isDeprecated) && (
|
|
<div className="space-y-4">
|
|
<h4 className="font-medium text-gray-900">
|
|
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
|
|
</h4>
|
|
|
|
<p className="text-sm text-gray-600">
|
|
Enter your Stripe API keys to enable payment collection.
|
|
You can find these in your{' '}
|
|
<a
|
|
href="https://dashboard.stripe.com/apikeys"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 hover:underline"
|
|
>
|
|
Stripe Dashboard
|
|
</a>
|
|
.
|
|
</p>
|
|
|
|
{/* Publishable Key */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Publishable Key
|
|
</label>
|
|
<div className="relative">
|
|
<Key
|
|
size={18}
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={publishableKey}
|
|
onChange={(e) => {
|
|
setPublishableKey(e.target.value);
|
|
setValidationResult(null);
|
|
}}
|
|
placeholder="pk_test_..."
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Secret Key */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Secret Key
|
|
</label>
|
|
<div className="relative">
|
|
<Key
|
|
size={18}
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
|
/>
|
|
<input
|
|
type={showSecretKey ? 'text' : 'password'}
|
|
value={secretKey}
|
|
onChange={(e) => {
|
|
setSecretKey(e.target.value);
|
|
setValidationResult(null);
|
|
}}
|
|
placeholder="sk_test_..."
|
|
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSecretKey(!showSecretKey)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
>
|
|
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Validation Result */}
|
|
{validationResult && (
|
|
<div
|
|
className={`flex items-start gap-2 p-3 rounded-lg ${
|
|
validationResult.valid
|
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
|
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
|
}`}
|
|
>
|
|
{validationResult.valid ? (
|
|
<CheckCircle size={18} className="shrink-0 mt-0.5" />
|
|
) : (
|
|
<AlertCircle size={18} className="shrink-0 mt-0.5" />
|
|
)}
|
|
<div className="text-sm flex-1">
|
|
{validationResult.valid ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-medium">Keys are valid!</span>
|
|
{validationResult.environment && (
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
|
|
validationResult.environment === 'test'
|
|
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
|
|
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
|
}`}
|
|
>
|
|
{validationResult.environment === 'test' ? (
|
|
<>
|
|
<FlaskConical size={10} />
|
|
Test Mode
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap size={10} />
|
|
Live Mode
|
|
</>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{validationResult.accountName && (
|
|
<div>Connected to: {validationResult.accountName}</div>
|
|
)}
|
|
{validationResult.environment === 'test' && (
|
|
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
|
|
These are test keys. No real payments will be processed.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span>{validationResult.error}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleValidate}
|
|
disabled={!secretKey || !publishableKey || validateMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{validateMutation.isPending ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<CheckCircle size={16} />
|
|
)}
|
|
Validate
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!canSave || saveMutation.isPending}
|
|
className="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 disabled:cursor-not-allowed"
|
|
>
|
|
{saveMutation.isPending ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<Key size={16} />
|
|
)}
|
|
Save Keys
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteConfirm && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
Remove API Keys?
|
|
</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Are you sure you want to remove your Stripe API keys?
|
|
You will not be able to accept payments until you add them again.
|
|
</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleteMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StripeApiKeysForm;
|