refactor: simplify API keys page - just paste .env and save

This commit is contained in:
Ajax Davis 2026-01-03 14:24:32 +10:00
parent 1d771efef6
commit 1469e36f3f

View file

@ -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>
);