refactor: simplify API keys page - just paste .env and save
This commit is contained in:
parent
1d771efef6
commit
1469e36f3f
1 changed files with 128 additions and 424 deletions
|
|
@ -1,47 +1,22 @@
|
|||
'use client';
|
||||
|
||||
import type { AIProvider } from '@tpmjs/types/agent';
|
||||
import { SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface ApiKeyInfo {
|
||||
provider: AIProvider;
|
||||
provider: string;
|
||||
keyHint: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
|
||||
OPENAI: 'OpenAI',
|
||||
ANTHROPIC: 'Anthropic',
|
||||
GOOGLE: 'Google',
|
||||
GROQ: 'Groq',
|
||||
MISTRAL: 'Mistral',
|
||||
};
|
||||
|
||||
const PROVIDER_DESCRIPTIONS: Record<AIProvider, string> = {
|
||||
OPENAI: 'GPT-4o, GPT-4 Turbo, GPT-3.5 Turbo',
|
||||
ANTHROPIC: 'Claude 3.5 Sonnet, Claude 3 Opus, Claude 3.5 Haiku',
|
||||
GOOGLE: 'Gemini 2.0 Flash, Gemini 1.5 Pro, Gemini 1.5 Flash',
|
||||
GROQ: 'Llama 3.3 70B, Llama 3.1 8B, Mixtral 8x7B',
|
||||
MISTRAL: 'Mistral Large, Mistral Small',
|
||||
};
|
||||
|
||||
const PROVIDER_LINKS: Record<AIProvider, { url: string; label: string }> = {
|
||||
OPENAI: { url: 'https://platform.openai.com/api-keys', label: 'platform.openai.com' },
|
||||
ANTHROPIC: { url: 'https://console.anthropic.com/settings/keys', label: 'console.anthropic.com' },
|
||||
GOOGLE: { url: 'https://aistudio.google.com/apikey', label: 'aistudio.google.com' },
|
||||
GROQ: { url: 'https://console.groq.com/keys', label: 'console.groq.com' },
|
||||
MISTRAL: { url: 'https://console.mistral.ai/api-keys', label: 'console.mistral.ai' },
|
||||
};
|
||||
|
||||
// Map common env var names to our provider enum
|
||||
const ENV_VAR_TO_PROVIDER: Record<string, AIProvider> = {
|
||||
// Map env var names to provider codes
|
||||
const ENV_VAR_MAP: Record<string, AIProvider> = {
|
||||
OPENAI_API_KEY: 'OPENAI',
|
||||
ANTHROPIC_API_KEY: 'ANTHROPIC',
|
||||
GOOGLE_API_KEY: 'GOOGLE',
|
||||
|
|
@ -55,35 +30,10 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Per-provider input state
|
||||
const [keyInputs, setKeyInputs] = useState<Record<AIProvider, string>>({
|
||||
OPENAI: '',
|
||||
ANTHROPIC: '',
|
||||
GOOGLE: '',
|
||||
GROQ: '',
|
||||
MISTRAL: '',
|
||||
});
|
||||
const [savingProviders, setSavingProviders] = useState<Set<AIProvider>>(new Set());
|
||||
const [savedProviders, setSavedProviders] = useState<Set<AIProvider>>(new Set());
|
||||
const [deletingProvider, setDeletingProvider] = useState<AIProvider | null>(null);
|
||||
|
||||
// .env paste state
|
||||
const [envText, setEnvText] = useState('');
|
||||
const [parsedKeys, setParsedKeys] = useState<{ provider: AIProvider; key: string }[]>([]);
|
||||
const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'done'>('idle');
|
||||
const [importResults, setImportResults] = useState<
|
||||
{ provider: AIProvider; success: boolean; error?: string }[]
|
||||
>([]);
|
||||
|
||||
// Auto-save debounce refs
|
||||
const saveTimeoutRefs = useRef<Record<AIProvider, NodeJS.Timeout | null>>({
|
||||
OPENAI: null,
|
||||
ANTHROPIC: null,
|
||||
GOOGLE: null,
|
||||
GROQ: null,
|
||||
MISTRAL: null,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveResult, setSaveResult] = useState<{ success: number; failed: number } | null>(null);
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -111,209 +61,90 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
fetchApiKeys();
|
||||
}, [fetchApiKeys]);
|
||||
|
||||
// Parse .env content
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simple env parsing with multiple conditionals
|
||||
const parseEnvContent = useCallback((text: string) => {
|
||||
const lines = text.split('\n');
|
||||
const keys: { provider: AIProvider; key: string }[] = [];
|
||||
const parseAndSave = useCallback(async () => {
|
||||
const lines = envText.split('\n');
|
||||
const keysToSave: { provider: AIProvider; apiKey: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Handle KEY=VALUE format (with optional quotes)
|
||||
const match = trimmed.match(/^([A-Z_]+)\s*=\s*["']?([^"'\s]+)["']?$/);
|
||||
const match = trimmed.match(/^([A-Z_]+)\s*=\s*["']?([^"'\n]+)["']?$/);
|
||||
if (match) {
|
||||
const envVar = match[1];
|
||||
const value = match[2];
|
||||
const [, envVar, value] = match;
|
||||
if (!envVar || !value) continue;
|
||||
const provider = ENV_VAR_TO_PROVIDER[envVar];
|
||||
const provider = ENV_VAR_MAP[envVar];
|
||||
if (provider) {
|
||||
// Check if this provider is already in our list
|
||||
const existingIndex = keys.findIndex((k) => k.provider === provider);
|
||||
if (existingIndex >= 0) {
|
||||
keys[existingIndex] = { provider, key: value };
|
||||
} else {
|
||||
keys.push({ provider, key: value });
|
||||
}
|
||||
keysToSave.push({ provider, apiKey: value.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}, []);
|
||||
|
||||
// Update parsed keys when env text changes
|
||||
useEffect(() => {
|
||||
const keys = parseEnvContent(envText);
|
||||
setParsedKeys(keys);
|
||||
}, [envText, parseEnvContent]);
|
||||
|
||||
// Save a single key
|
||||
const saveKey = useCallback(async (provider: AIProvider, apiKey: string) => {
|
||||
setSavingProviders((prev) => new Set(prev).add(provider));
|
||||
setSavedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(provider);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, apiKey }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setApiKeys((prev) => {
|
||||
const existing = prev.findIndex((k) => k.provider === provider);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = result.data;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, result.data];
|
||||
});
|
||||
setSavedProviders((prev) => new Set(prev).add(provider));
|
||||
// Clear input after successful save
|
||||
setKeyInputs((prev) => ({ ...prev, [provider]: '' }));
|
||||
// Clear saved indicator after 2 seconds
|
||||
setTimeout(() => {
|
||||
setSavedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(provider);
|
||||
return next;
|
||||
});
|
||||
}, 2000);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
} catch (err) {
|
||||
console.error('Failed to save API key:', err);
|
||||
return { success: false, error: 'Network error' };
|
||||
} finally {
|
||||
setSavingProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(provider);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle input change with debounced auto-save
|
||||
const handleKeyInputChange = useCallback(
|
||||
(provider: AIProvider, value: string) => {
|
||||
setKeyInputs((prev) => ({ ...prev, [provider]: value }));
|
||||
|
||||
// Clear existing timeout
|
||||
const existingTimeout = saveTimeoutRefs.current[provider];
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for auto-save (1.5 seconds after typing stops)
|
||||
if (value.trim()) {
|
||||
saveTimeoutRefs.current[provider] = setTimeout(() => {
|
||||
saveKey(provider, value.trim());
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
[saveKey]
|
||||
);
|
||||
|
||||
// Handle blur - save immediately
|
||||
const handleKeyInputBlur = useCallback(
|
||||
(provider: AIProvider) => {
|
||||
const value = keyInputs[provider].trim();
|
||||
if (value) {
|
||||
// Clear any pending timeout
|
||||
const pendingTimeout = saveTimeoutRefs.current[provider];
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
saveTimeoutRefs.current[provider] = null;
|
||||
}
|
||||
saveKey(provider, value);
|
||||
}
|
||||
},
|
||||
[keyInputs, saveKey]
|
||||
);
|
||||
|
||||
// Import all parsed keys
|
||||
const handleImportAll = useCallback(async () => {
|
||||
if (parsedKeys.length === 0) return;
|
||||
|
||||
setImportStatus('importing');
|
||||
setImportResults([]);
|
||||
|
||||
const results: { provider: AIProvider; success: boolean; error?: string }[] = [];
|
||||
|
||||
for (const { provider, key } of parsedKeys) {
|
||||
const result = await saveKey(provider, key);
|
||||
results.push({
|
||||
provider,
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setImportResults(results);
|
||||
setImportStatus('done');
|
||||
setEnvText('');
|
||||
setParsedKeys([]);
|
||||
|
||||
// Clear results after 3 seconds
|
||||
setTimeout(() => {
|
||||
setImportResults([]);
|
||||
setImportStatus('idle');
|
||||
}, 3000);
|
||||
}, [parsedKeys, saveKey]);
|
||||
|
||||
const handleDeleteKey = useCallback(async (provider: AIProvider) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete your ${PROVIDER_DISPLAY_NAMES[provider]} API key?`)
|
||||
) {
|
||||
if (keysToSave.length === 0) {
|
||||
setSaveResult({ success: 0, failed: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingProvider(provider);
|
||||
try {
|
||||
const response = await fetch(`/api/user/api-keys/${provider}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setIsSaving(true);
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setApiKeys((prev) => prev.filter((k) => k.provider !== provider));
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete API key');
|
||||
for (const { provider, apiKey } of keysToSave) {
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, apiKey }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete API key:', err);
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete API key');
|
||||
} finally {
|
||||
setDeletingProvider(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider);
|
||||
const getKeyInfo = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider);
|
||||
setSaveResult({ success, failed });
|
||||
setIsSaving(false);
|
||||
setEnvText('');
|
||||
fetchApiKeys();
|
||||
|
||||
setTimeout(() => setSaveResult(null), 3000);
|
||||
}, [envText, fetchApiKeys]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (provider: string) => {
|
||||
if (!confirm(`Delete this API key?`)) return;
|
||||
|
||||
setDeletingKey(provider);
|
||||
try {
|
||||
const response = await fetch(`/api/user/api-keys/${provider}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setApiKeys((prev) => prev.filter((k) => k.provider !== provider));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err);
|
||||
} finally {
|
||||
setDeletingKey(null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-surface-secondary rounded w-48 mb-8" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 bg-surface-secondary rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto py-12 px-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-surface-secondary rounded w-32" />
|
||||
<div className="h-32 bg-surface-secondary rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -324,13 +155,9 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Button onClick={fetchApiKeys}>Try Again</Button>
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto py-12 px-4 text-center">
|
||||
<p className="text-error mb-4">{error}</p>
|
||||
<Button onClick={fetchApiKeys}>Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -339,207 +166,84 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<div className="max-w-2xl mx-auto py-12 px-4">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Link href="/dashboard" className="text-foreground-secondary hover:text-foreground">
|
||||
<Icon icon="arrowLeft" size="sm" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">API Keys</h1>
|
||||
<p className="text-foreground-secondary mt-1">
|
||||
Manage your AI provider API keys for agents
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">API Keys</h1>
|
||||
</div>
|
||||
|
||||
{/* Quick Import Section */}
|
||||
<div className="bg-surface-secondary border border-border rounded-lg p-6 mb-8">
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Quick Import</h2>
|
||||
<p className="text-sm text-foreground-secondary mb-4">
|
||||
Paste your .env file or environment variables below to import multiple keys at once.
|
||||
</p>
|
||||
|
||||
{/* Paste .env */}
|
||||
<div className="mb-8">
|
||||
<textarea
|
||||
value={envText}
|
||||
onChange={(e) => setEnvText(e.target.value)}
|
||||
placeholder={`OPENAI_API_KEY=sk-...
|
||||
placeholder="Paste your .env file or API keys here...
|
||||
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GOOGLE_API_KEY=...
|
||||
GROQ_API_KEY=gsk_...
|
||||
MISTRAL_API_KEY=...`}
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-lg text-foreground font-mono text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
|
||||
MISTRAL_API_KEY=..."
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 bg-surface-secondary border border-border rounded-lg text-foreground font-mono text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
|
||||
{/* Parsed keys preview */}
|
||||
{parsedKeys.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-foreground-secondary">Detected:</span>
|
||||
{parsedKeys.map(({ provider }) => (
|
||||
<span
|
||||
key={provider}
|
||||
className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary"
|
||||
>
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}
|
||||
</span>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleImportAll}
|
||||
disabled={importStatus === 'importing'}
|
||||
className="ml-auto"
|
||||
>
|
||||
{importStatus === 'importing' ? (
|
||||
<>
|
||||
<Icon icon="loader" size="xs" className="animate-spin mr-1" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="plus" size="xs" className="mr-1" />
|
||||
Import {parsedKeys.length} key{parsedKeys.length > 1 ? 's' : ''}
|
||||
</>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button onClick={parseAndSave} disabled={isSaving || !envText.trim()}>
|
||||
{isSaving ? 'Saving...' : 'Save Keys'}
|
||||
</Button>
|
||||
{saveResult && (
|
||||
<span className="text-sm text-foreground-secondary">
|
||||
{saveResult.success > 0 && (
|
||||
<span className="text-green-500">{saveResult.success} saved</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{saveResult.failed > 0 && (
|
||||
<span className="text-red-500 ml-2">{saveResult.failed} failed</span>
|
||||
)}
|
||||
{saveResult.success === 0 && saveResult.failed === 0 && (
|
||||
<span>No recognized keys found</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import results */}
|
||||
{importResults.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{importResults.map(({ provider, success, error: err }) => (
|
||||
{/* Stored keys */}
|
||||
{apiKeys.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground-secondary mb-3">Stored Keys</h2>
|
||||
<div className="space-y-2">
|
||||
{apiKeys.map((key) => (
|
||||
<div
|
||||
key={provider}
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
success
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
key={key.provider}
|
||||
className="flex items-center justify-between px-4 py-3 bg-surface-secondary border border-border rounded-lg"
|
||||
>
|
||||
<Icon icon={success ? 'check' : 'x'} size="xs" />
|
||||
<span>
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}: {success ? 'Saved' : err || 'Failed'}
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">{key.provider}</span>
|
||||
<span className="text-foreground-tertiary ml-2 text-sm">
|
||||
...{key.keyHint}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(key.provider)}
|
||||
disabled={deletingKey === key.provider}
|
||||
className="text-foreground-tertiary hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
<Icon icon="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="info" size="sm" className="text-primary mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="text-foreground">
|
||||
Your API keys are encrypted and stored securely. Keys auto-save when you stop typing
|
||||
or leave the field.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Rows */}
|
||||
<div className="space-y-3">
|
||||
{SUPPORTED_PROVIDERS.map((provider) => {
|
||||
const keyInfo = getKeyInfo(provider);
|
||||
const isSaving = savingProviders.has(provider);
|
||||
const isSaved = savedProviders.has(provider);
|
||||
const isDeleting = deletingProvider === provider;
|
||||
const link = PROVIDER_LINKS[provider];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="bg-background border border-border rounded-lg p-4 flex items-center gap-4"
|
||||
>
|
||||
{/* Provider info */}
|
||||
<div className="flex-shrink-0 w-32">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}
|
||||
</span>
|
||||
{hasKey(provider) && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" title="Configured" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-0.5">
|
||||
{keyInfo ? `...${keyInfo.keyHint}` : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="password"
|
||||
value={keyInputs[provider]}
|
||||
onChange={(e) => handleKeyInputChange(provider, e.target.value)}
|
||||
onBlur={() => handleKeyInputBlur(provider)}
|
||||
placeholder={
|
||||
hasKey(provider) ? 'Enter new key to update...' : 'Paste API key...'
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-secondary border border-border rounded-lg text-foreground text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary font-mono"
|
||||
/>
|
||||
{/* Status indicator */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{isSaving && (
|
||||
<Icon
|
||||
icon="loader"
|
||||
size="xs"
|
||||
className="animate-spin text-foreground-tertiary"
|
||||
/>
|
||||
)}
|
||||
{isSaved && !isSaving && (
|
||||
<Icon icon="check" size="xs" className="text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline whitespace-nowrap"
|
||||
>
|
||||
Get key →
|
||||
</a>
|
||||
{hasKey(provider) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteKey(provider)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 text-foreground-tertiary hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
title="Delete key"
|
||||
>
|
||||
<Icon icon="trash" size="xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Models reference */}
|
||||
<div className="mt-8 pt-8 border-t border-border">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Supported Models</h3>
|
||||
<div className="grid md:grid-cols-2 gap-3 text-sm">
|
||||
{SUPPORTED_PROVIDERS.map((provider) => (
|
||||
<div key={provider} className="flex gap-2">
|
||||
<span className="font-medium text-foreground-secondary w-24">
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}:
|
||||
</span>
|
||||
<span className="text-foreground-tertiary">{PROVIDER_DESCRIPTIONS[provider]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{apiKeys.length === 0 && (
|
||||
<p className="text-center text-foreground-tertiary py-8">
|
||||
No API keys stored yet. Paste your .env above to add keys.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue