kaboot/components/ApiKeyModal.tsx

556 lines
23 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search, CreditCard, CheckCircle } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useAuthenticatedFetch } from '../hooks/useAuthenticatedFetch';
import type { AIProvider, UserPreferences } from '../types';
interface SubscriptionInfo {
hasAccess: boolean;
accessType: 'group' | 'subscription' | 'none';
generationCount: number | null;
generationLimit: number | null;
generationsRemaining: number | null;
}
interface OpenAIModel {
id: string;
owned_by: string;
}
interface ApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
preferences: UserPreferences;
onSave: (prefs: Partial<UserPreferences>) => Promise<void>;
saving: boolean;
hasAIAccess: boolean;
subscription?: SubscriptionInfo | null;
hasEarlyAccess?: boolean;
}
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
isOpen,
onClose,
preferences,
onSave,
saving,
hasAIAccess,
subscription,
hasEarlyAccess,
}) => {
useBodyScrollLock(isOpen);
const { authFetch } = useAuthenticatedFetch();
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || '');
const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || '');
const [localOpenAIKey, setLocalOpenAIKey] = useState(preferences.openAIApiKey || '');
const [localOpenAIModel, setLocalOpenAIModel] = useState(preferences.openAIModel || '');
const [showGeminiKey, setShowGeminiKey] = useState(false);
const [showOpenRouterKey, setShowOpenRouterKey] = useState(false);
const [showOpenAIKey, setShowOpenAIKey] = useState(false);
const [openAIModels, setOpenAIModels] = useState<OpenAIModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [portalError, setPortalError] = useState<string | null>(null);
const [showPortalBanner, setShowPortalBanner] = useState(false);
useEffect(() => {
if (isOpen) {
setLocalProvider(preferences.aiProvider || 'gemini');
setLocalGeminiKey(preferences.geminiApiKey || '');
setLocalGeminiModel(preferences.geminiModel || '');
setLocalOpenRouterKey(preferences.openRouterApiKey || '');
setLocalOpenRouterModel(preferences.openRouterModel || '');
setLocalOpenAIKey(preferences.openAIApiKey || '');
setLocalOpenAIModel(preferences.openAIModel || '');
setModelSearchQuery('');
setIsModelDropdownOpen(false);
const params = new URLSearchParams(window.location.search);
if (params.get('portal') === 'return') {
setShowPortalBanner(true);
}
}
}, [isOpen, preferences]);
const dismissPortalBanner = () => {
setShowPortalBanner(false);
const url = new URL(window.location.href);
url.searchParams.delete('portal');
window.history.replaceState({}, '', url.toString());
};
useEffect(() => {
const fetchOpenAIModels = async () => {
if (!localOpenAIKey || localOpenAIKey.length < 10) {
setOpenAIModels([]);
return;
}
setLoadingModels(true);
try {
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${localOpenAIKey}`,
},
});
if (response.ok) {
const data = await response.json();
const models = (data.data as (OpenAIModel & { created: number })[])
.filter(m => !m.id.includes('realtime') && !m.id.includes('audio') && !m.id.includes('tts') && !m.id.includes('whisper') && !m.id.includes('dall-e') && !m.id.includes('embedding'))
.sort((a, b) => b.created - a.created);
setOpenAIModels(models);
} else {
setOpenAIModels([]);
}
} catch {
setOpenAIModels([]);
} finally {
setLoadingModels(false);
}
};
const debounceTimer = setTimeout(fetchOpenAIModels, 500);
return () => clearTimeout(debounceTimer);
}, [localOpenAIKey]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
setIsModelDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredModels = openAIModels.filter(model =>
model.id.toLowerCase().includes(modelSearchQuery.toLowerCase())
);
if (!isOpen) return null;
const handleSave = async () => {
await onSave({
aiProvider: localProvider,
geminiApiKey: localGeminiKey || undefined,
geminiModel: localGeminiModel || undefined,
openRouterApiKey: localOpenRouterKey || undefined,
openRouterModel: localOpenRouterModel || undefined,
openAIApiKey: localOpenAIKey || undefined,
openAIModel: localOpenAIModel || undefined,
});
onClose();
};
const handleManageSubscription = async () => {
setPortalLoading(true);
setPortalError(null);
try {
const response = await authFetch('/api/payments/portal', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
returnUrl: `${window.location.origin}?portal=return`,
}),
});
if (!response.ok) {
throw new Error('Failed to create customer portal session');
}
const data = await response.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No portal URL returned');
}
} catch (err) {
console.error('Portal error:', err);
setPortalError('Failed to open subscription management. Please try again.');
setPortalLoading(false);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-visible"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900 rounded-t-2xl">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Key size={24} />
</div>
<div>
<h2 className="text-xl font-black">AI Settings</h2>
<p className="text-sm opacity-80">Configure your AI provider</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
>
<X size={24} />
</button>
</div>
<div className="p-6 bg-gray-50 space-y-4">
{showPortalBanner && (
<div className="bg-green-50 p-4 rounded-xl border border-green-200 shadow-sm flex items-start gap-3">
<div className="p-1 bg-green-100 rounded-lg text-green-600">
<CheckCircle size={20} />
</div>
<div className="flex-1">
<h3 className="font-bold text-green-900">Subscription Updated</h3>
<p className="text-sm text-green-700 mt-1">
Your subscription settings have been updated successfully.
</p>
</div>
<button
onClick={dismissPortalBanner}
className="text-green-500 hover:text-green-700 transition"
>
<X size={20} />
</button>
</div>
)}
{subscription?.accessType === 'subscription' && (
<div className="mb-6 bg-white p-4 rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-theme-primary font-bold">
<CreditCard size={20} />
<span>Subscription Active</span>
</div>
{subscription.generationsRemaining !== null && (
<span className="text-sm font-bold bg-theme-primary/10 text-theme-primary px-2 py-1 rounded-lg">
{subscription.generationsRemaining} generations left
</span>
)}
</div>
<p className="text-sm text-gray-500 mb-3">
Manage your billing, payment methods, and invoices.
</p>
{portalError && (
<p className="text-sm text-red-500 mb-3 bg-red-50 p-2 rounded-lg border border-red-100">
{portalError}
</p>
)}
<button
onClick={handleManageSubscription}
disabled={portalLoading}
className="w-full py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg font-bold text-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
{portalLoading ? (
<>
<Loader2 size={16} className="animate-spin" />
Redirecting...
</>
) : (
'Manage Subscription'
)}
</button>
</div>
)}
<div>
<label className="block font-bold text-gray-800 mb-2">AI Provider</label>
<div className="flex bg-gray-200 p-1 rounded-xl">
<button
type="button"
onClick={() => setLocalProvider('gemini')}
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'gemini'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Gemini
</button>
<button
type="button"
onClick={() => setLocalProvider('openai')}
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'openai'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
}`}
>
OpenAI
</button>
<button
type="button"
onClick={() => setLocalProvider('openrouter')}
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
localProvider === 'openrouter'
? 'bg-white shadow-sm text-gray-800'
: 'text-gray-500 hover:text-gray-700'
}`}
>
OpenRouter
</button>
</div>
<p className="text-xs text-gray-500 mt-2 ml-1">
Choose the provider you want to use for quiz generation.
</p>
</div>
{localProvider === 'gemini' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
Gemini API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
aistudio.google.com/apikey
</a>
{hasAIAccess ? ' or leave empty to use the system key.' : '.'}
</p>
<div className="relative">
<input
type={showGeminiKey ? 'text' : 'password'}
value={localGeminiKey}
onChange={(e) => setLocalGeminiKey(e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
Default: gemini-3-flash-preview
</p>
<input
type="text"
value={localGeminiModel}
onChange={(e) => setLocalGeminiModel(e.target.value)}
placeholder="gemini-3-flash-preview"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
</div>
</>
)}
{localProvider === 'openai' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
OpenAI API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
platform.openai.com/api-keys
</a>
</p>
<div className="relative">
<input
type={showOpenAIKey ? 'text' : 'password'}
value={localOpenAIKey}
onChange={(e) => setLocalOpenAIKey(e.target.value)}
placeholder="sk-..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowOpenAIKey(!showOpenAIKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showOpenAIKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
{localOpenAIKey ? 'Select a model from your account' : 'Enter API key to load models'}
</p>
<div className="relative" ref={modelDropdownRef}>
<div
className={`w-full px-4 py-3 rounded-xl border-2 flex items-center justify-between cursor-pointer transition-colors ${
isModelDropdownOpen ? 'border-theme-primary' : 'border-gray-200'
} ${!localOpenAIKey ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
onClick={() => localOpenAIKey && setIsModelDropdownOpen(!isModelDropdownOpen)}
>
<span className={localOpenAIModel ? 'text-gray-800' : 'text-gray-400'}>
{localOpenAIModel || 'gpt-5-mini'}
</span>
{loadingModels ? (
<Loader2 size={18} className="animate-spin text-gray-400" />
) : (
<ChevronDown size={18} className={`text-gray-400 transition-transform ${isModelDropdownOpen ? 'rotate-180' : ''}`} />
)}
</div>
{isModelDropdownOpen && openAIModels.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border-2 border-gray-200 rounded-xl shadow-lg overflow-hidden">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..."
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
autoFocus
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{filteredModels.length > 0 ? (
filteredModels.map((model) => (
<button
key={model.id}
type="button"
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors ${
localOpenAIModel === model.id ? 'bg-theme-primary/10 text-theme-primary font-medium' : 'text-gray-700'
}`}
onClick={() => {
setLocalOpenAIModel(model.id);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}}
>
{model.id}
</button>
))
) : (
<div className="px-4 py-3 text-sm text-gray-500 text-center">
No models found
</div>
)}
</div>
</div>
)}
</div>
</div>
</>
)}
{localProvider === 'openrouter' && (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
OpenRouter API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
openrouter.ai/keys
</a>
</p>
<div className="relative">
<input
type={showOpenRouterKey ? 'text' : 'password'}
value={localOpenRouterKey}
onChange={(e) => setLocalOpenRouterKey(e.target.value)}
placeholder="sk-or-..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowOpenRouterKey(!showOpenRouterKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showOpenRouterKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
Default: google/gemini-3-flash-preview
</p>
<input
type="text"
value={localOpenRouterModel}
onChange={(e) => setLocalOpenRouterModel(e.target.value)}
placeholder="google/gemini-3-flash-preview"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
</div>
</>
)}
</div>
<div className="p-6 border-t border-gray-100 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save'
)}
</button>
</div>
</motion.div>
</motion.div>
);
};