← vibesdk
/**
* BYOK API Keys Modal - Uses vault for secure key storage
* Tab 1: Add new keys (stores in vault with metadata.provider)
* Tab 2: Manage existing keys with delete functionality
*/
import { useState, useEffect, useCallback } from 'react';
import { Key, Check, AlertCircle, Loader2, Plus, Settings, Trash2, Eye, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tabs } from '@cloudflare/kumo';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { apiClient } from '@/lib/api-client';
import { useVault, type SecretListItem } from '@/contexts/vault-context';
import { VaultUnlockModal } from '@/components/vault/VaultUnlockModal';
import { VaultSetupWizard } from '@/components/vault/VaultSetupWizard';
import { toast } from 'sonner';
import type { SecretTemplate } from '@/api-types';
// Import provider logos
import OpenAILogo from '@/assets/provider-logos/openai.svg?react';
import AnthropicLogo from '@/assets/provider-logos/anthropic.svg?react';
import GoogleLogo from '@/assets/provider-logos/google.svg?react';
import CerebrasLogo from '@/assets/provider-logos/cerebras.svg?react';
import CloudflareLogo from '@/assets/provider-logos/cloudflare.svg?react';
interface ByokApiKeysModalProps {
isOpen: boolean;
onClose: () => void;
onKeyAdded?: () => void;
}
interface ManagedSecret {
id: string;
name: string;
provider: string;
envVarName?: string;
createdAt: string;
logo: React.ComponentType<{ className?: string }>;
}
// Logo mapping for dynamic provider support
const PROVIDER_LOGOS: Record<string, React.ComponentType<{ className?: string }>> = {
openai: OpenAILogo,
anthropic: AnthropicLogo,
'google-ai-studio': GoogleLogo,
cerebras: CerebrasLogo,
};
interface BYOKProvider {
id: string;
name: string;
provider: string;
envVarName: string;
logo: React.ComponentType<{ className?: string }>;
placeholder: string;
validation: RegExp;
}
/**
* Convert BYOK template to provider configuration
*/
function templateToBYOKProvider(template: SecretTemplate): BYOKProvider {
const logo = PROVIDER_LOGOS[template.provider] || (() => <div className="w-4 h-4 bg-gray-300 rounded" />);
return {
id: template.id,
name: template.displayName.replace(' (BYOK)', ''),
provider: template.provider,
envVarName: template.envVarName,
logo,
placeholder: template.placeholder,
validation: new RegExp(template.validation),
};
}
export function ByokApiKeysModal({ isOpen, onClose, onKeyAdded }: ByokApiKeysModalProps) {
const { state, isUnlocked, encryptAndStoreSecret, listSecrets, deleteSecret } = useVault();
// Tab management
const [activeTab, setActiveTab] = useState<'add' | 'manage'>('add');
// Add keys tab state
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [apiKey, setApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [byokProviders, setBYOKProviders] = useState<BYOKProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Manage keys tab state
const [managedSecrets, setManagedSecrets] = useState<ManagedSecret[]>([]);
const [loadingSecrets, setLoadingSecrets] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [secretToDelete, setSecretToDelete] = useState<ManagedSecret | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Vault modals
const [showUnlockModal, setShowUnlockModal] = useState(false);
const [showSetupModal, setShowSetupModal] = useState(false);
// Get selected provider details
const provider = byokProviders.find((p) => p.id === selectedProvider);
const loadBYOKProviders = useCallback(async () => {
try {
setIsLoading(true);
const response = await apiClient.getBYOKTemplates();
if (response.success && response.data) {
const providers = response.data.templates.map(templateToBYOKProvider);
setBYOKProviders(providers);
} else {
toast.error('Failed to load BYOK providers');
}
} catch (error) {
console.error('Error loading BYOK templates:', error);
toast.error('Failed to load BYOK providers');
} finally {
setIsLoading(false);
}
}, []);
const loadManagedSecrets = useCallback(async () => {
if (!isUnlocked) return;
try {
setLoadingSecrets(true);
const secrets = await listSecrets();
// Filter secrets that have provider metadata (BYOK keys)
const byokSecrets: ManagedSecret[] = secrets
.filter((secret: SecretListItem) => secret.metadata?.provider)
.map((secret: SecretListItem) => {
const providerKey = secret.metadata?.provider as string;
const logo =
PROVIDER_LOGOS[providerKey] || (() => <div className="w-4 h-4 bg-gray-300 rounded" />);
return {
id: secret.id,
name: secret.name,
provider: providerKey,
envVarName: secret.metadata?.envVarName as string | undefined,
createdAt: secret.createdAt,
logo,
};
});
setManagedSecrets(byokSecrets);
} catch (error) {
console.error('Error loading managed secrets:', error);
toast.error('Failed to load API keys');
} finally {
setLoadingSecrets(false);
}
}, [isUnlocked, listSecrets]);
// Load BYOK templates when modal opens
useEffect(() => {
if (isOpen) {
// Reset add keys tab
setSelectedProvider(null);
setApiKey('');
setIsSaving(false);
// Reset manage keys tab
setDeleteDialogOpen(false);
setSecretToDelete(null);
setIsDeleting(false);
// Load data
loadBYOKProviders();
}
}, [isOpen, loadBYOKProviders]);
// Load secrets when vault is unlocked
useEffect(() => {
if (isOpen && isUnlocked) {
loadManagedSecrets();
} else if (isOpen && !isUnlocked) {
setManagedSecrets([]);
}
}, [isOpen, isUnlocked, loadManagedSecrets]);
// Handle provider selection
const handleProviderSelect = (providerId: string) => {
setSelectedProvider(providerId);
setApiKey('');
};
// Validate key format
const isKeyFormatValid = provider && apiKey && provider.validation.test(apiKey);
// Save API key to vault
const handleSaveKey = async () => {
if (!provider || !apiKey || !isKeyFormatValid) return;
// Check vault status
if (state.status === 'not_setup') {
setShowSetupModal(true);
return;
}
if (!isUnlocked) {
setShowUnlockModal(true);
return;
}
setIsSaving(true);
try {
// Store in vault with provider metadata
const secretId = await encryptAndStoreSecret(provider.name, apiKey.trim(), {
provider: provider.provider,
envVarName: provider.envVarName,
});
if (secretId) {
toast.success(`${provider.name} API key added successfully!`);
onKeyAdded?.();
// Reload managed secrets and switch to manage tab
await loadManagedSecrets();
setActiveTab('manage');
// Reset add form
setSelectedProvider(null);
setApiKey('');
} else {
toast.error('Failed to save API key');
}
} catch (error) {
console.error('Failed to save API key:', error);
toast.error('Failed to save API key. Please try again.');
} finally {
setIsSaving(false);
}
};
// Delete secret from vault
const handleDeleteSecret = async () => {
if (!secretToDelete) return;
setIsDeleting(true);
try {
const success = await deleteSecret(secretToDelete.id);
if (success) {
toast.success(`${secretToDelete.name} API key deleted successfully`);
// Remove from local state
setManagedSecrets((prev) => prev.filter((secret) => secret.id !== secretToDelete.id));
// Notify parent about key changes
onKeyAdded?.();
} else {
toast.error('Failed to delete API key');
}
// Close dialog
setDeleteDialogOpen(false);
setSecretToDelete(null);
} catch (error) {
console.error('Error deleting secret:', error);
toast.error('Failed to delete API key');
} finally {
setIsDeleting(false);
}
};
const openDeleteDialog = (secret: ManagedSecret) => {
setSecretToDelete(secret);
setDeleteDialogOpen(true);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
// Handle vault setup complete
const handleSetupComplete = () => {
setShowSetupModal(false);
// Secrets will reload via useEffect when isUnlocked changes
};
// Render vault locked state for manage tab
const renderVaultLockedState = () => (
<div className="text-center py-8 text-kumo-subtle">
<Lock className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">Vault is locked</p>
<p className="text-sm mb-4">Unlock your vault to view and manage API keys</p>
<Button onClick={() => setShowUnlockModal(true)}>
<Lock className="h-4 w-4 mr-2" />
Unlock Vault
</Button>
</div>
);
// Render vault not setup state
const renderVaultNotSetupState = () => (
<div className="text-center py-8 text-kumo-subtle">
<Key className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">Set up your secure vault</p>
<p className="text-sm mb-4">Create a vault to securely store your API keys</p>
<Button onClick={() => setShowSetupModal(true)}>
<Key className="h-4 w-4 mr-2" />
Set Up Vault
</Button>
</div>
);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Bring Your Own Key
<span className="flex items-center gap-1 text-xs text-kumo-subtle font-normal">
via <CloudflareLogo className="h-3 w-3" /> AI Gateway
</span>
</DialogTitle>
<DialogDescription>
Add your API keys to use your own provider accounts for billing, or manage existing keys
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'add' | 'manage')}
tabs={[
{
value: 'add',
label: (
<span className="inline-flex items-center gap-2">
<Plus className="h-4 w-4" />
Add Keys
</span>
),
},
{
value: 'manage',
label: (
<span className="inline-flex items-center gap-2">
<Settings className="h-4 w-4" />
Manage Keys
</span>
),
},
]}
/>
{/* Add Keys Tab */}
{activeTab === 'add' && (
<div className="space-y-6">
{/* Provider Selection - Clean List */}
<div className="space-y-3">
<Label className="text-sm font-medium">Select Provider</Label>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="w-full flex items-center gap-3 p-3 rounded-lg border-2 border-gray-200"
>
<div className="w-8 h-8 bg-gray-200 rounded-md animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse flex-1" />
</div>
))}
</div>
) : (
<div className="space-y-2">
{byokProviders.map((providerOption) => {
const LogoComponent = providerOption.logo;
const isSelected = selectedProvider === providerOption.id;
return (
<button
key={providerOption.id}
onClick={() => handleProviderSelect(providerOption.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-all duration-200 text-left ${
isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center w-8 h-8 bg-white rounded-md border shadow-sm">
<LogoComponent className="h-5 w-5" />
</div>
<span className="font-medium">{providerOption.name}</span>
</button>
);
})}
</div>
)}
</div>
{/* API Key Input - Smooth Expansion */}
{selectedProvider && provider && (
<div className="space-y-3 animate-in slide-in-from-top-2 duration-300">
<Label htmlFor="apiKey" className="text-sm font-medium">
Enter your {provider.name} API key
</Label>
<div className="relative">
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider.placeholder}
className={`pr-10 ${
apiKey
? isKeyFormatValid
? 'border-green-500 focus:border-green-500'
: 'border-red-500 focus:border-red-500'
: ''
}`}
/>
{apiKey && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{isKeyFormatValid ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
</div>
)}
</div>
{apiKey && !isKeyFormatValid && (
<p className="text-xs text-red-600">
Invalid format. Expected: {provider.placeholder}
</p>
)}
</div>
)}
</div>
)}
{/* Manage Keys Tab */}
{activeTab === 'manage' && (
<div className="space-y-4">
{state.status === 'not_setup' ? (
renderVaultNotSetupState()
) : !isUnlocked ? (
renderVaultLockedState()
) : loadingSecrets ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
<div className="w-8 h-8 bg-gray-200 rounded-md animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/3" />
<div className="h-3 bg-gray-200 rounded animate-pulse w-1/2" />
</div>
<div className="w-12 h-6 bg-gray-200 rounded animate-pulse" />
</div>
))}
</div>
) : managedSecrets.length === 0 ? (
<div className="text-center py-8 text-kumo-subtle">
<Key className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No API keys configured</p>
<p className="text-sm">Add your first API key using the "Add Keys" tab</p>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Your API Keys</Label>
<Badge variant="secondary">{managedSecrets.length} total</Badge>
</div>
<div className="space-y-3">
{managedSecrets.map((secret) => {
const LogoComponent = secret.logo;
return (
<div
key={secret.id}
className="flex items-center gap-4 p-4 rounded-lg border transition-colors hover:bg-kumo-base/50"
>
{/* Provider Logo */}
<div className="flex items-center justify-center w-8 h-8 rounded-md border shadow-sm bg-white">
<LogoComponent className="h-5 w-5" />
</div>
{/* Key Info */}
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium capitalize">{secret.name}</span>
</div>
<div className="flex items-center gap-3 text-xs text-kumo-subtle">
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{secret.envVarName || secret.provider}</span>
</div>
<Separator orientation="vertical" className="h-3" />
<span>Added {formatDate(secret.createdAt)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(secret)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
{activeTab === 'add' && selectedProvider && (
<Button onClick={handleSaveKey} disabled={!apiKey || !isKeyFormatValid || isSaving} className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="h-4 w-4" />
Add Key
</>
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete API Key</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the {secretToDelete?.name} API key? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteSecret}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Deleting...
</>
) : (
'Delete Key'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Vault Unlock Modal */}
<VaultUnlockModal open={showUnlockModal} onOpenChange={setShowUnlockModal} />
{/* Vault Setup Modal */}
<Dialog open={showSetupModal} onOpenChange={setShowSetupModal}>
<DialogContent className="sm:max-w-lg p-0 overflow-hidden">
<VaultSetupWizard
open={showSetupModal}
onComplete={handleSetupComplete}
onCancel={() => setShowSetupModal(false)}
/>
</DialogContent>
</Dialog>
</>
);
}