From 1b1c4f6474e1899081d01056fe442fab1ee22da9 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 3 Jan 2026 13:27:48 +1000 Subject: [PATCH] feat: redesign API keys settings page with .env paste support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Quick Import section to paste entire .env files - Auto-detect supported providers (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) - Add per-provider inline inputs with auto-save on blur/debounce - Show save status indicators (loading spinner, checkmark) - Simplify UI with compact provider rows - Add supported models reference section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/dashboard/settings/api-keys/page.tsx | 502 ++++++++++++------ 1 file changed, 342 insertions(+), 160 deletions(-) diff --git a/apps/web/src/app/dashboard/settings/api-keys/page.tsx b/apps/web/src/app/dashboard/settings/api-keys/page.tsx index 8a7b047..d6f0dfd 100644 --- a/apps/web/src/app/dashboard/settings/api-keys/page.tsx +++ b/apps/web/src/app/dashboard/settings/api-keys/page.tsx @@ -6,7 +6,7 @@ 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, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; interface ApiKeyInfo { @@ -32,16 +32,59 @@ const PROVIDER_DESCRIPTIONS: Record = { MISTRAL: 'Mistral Large, Mistral Small', }; +const PROVIDER_LINKS: Record = { + 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 = { + OPENAI_API_KEY: 'OPENAI', + ANTHROPIC_API_KEY: 'ANTHROPIC', + GOOGLE_API_KEY: 'GOOGLE', + GOOGLE_GENERATIVE_AI_API_KEY: 'GOOGLE', + GROQ_API_KEY: 'GROQ', + MISTRAL_API_KEY: 'MISTRAL', +}; + export default function ApiKeysPage(): React.ReactElement { const router = useRouter(); const [apiKeys, setApiKeys] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [addingProvider, setAddingProvider] = useState(null); - const [newApiKey, setNewApiKey] = useState(''); - const [isSaving, setIsSaving] = useState(false); + + // Per-provider input state + const [keyInputs, setKeyInputs] = useState>({ + OPENAI: '', + ANTHROPIC: '', + GOOGLE: '', + GROQ: '', + MISTRAL: '', + }); + const [savingProviders, setSavingProviders] = useState>(new Set()); + const [savedProviders, setSavedProviders] = useState>(new Set()); const [deletingProvider, setDeletingProvider] = useState(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>({ + OPENAI: null, + ANTHROPIC: null, + GOOGLE: null, + GROQ: null, + MISTRAL: null, + }); + const fetchApiKeys = useCallback(async () => { try { const response = await fetch('/api/user/api-keys'); @@ -68,21 +111,63 @@ export default function ApiKeysPage(): React.ReactElement { fetchApiKeys(); }, [fetchApiKeys]); - const handleAddKey = async (provider: AIProvider) => { - if (!newApiKey.trim()) return; + // 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 }[] = []; + + 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]+)["']?$/); + if (match) { + const envVar = match[1]; + const value = match[2]; + if (!envVar || !value) continue; + const provider = ENV_VAR_TO_PROVIDER[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 }); + } + } + } + } + + 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; + }); - setIsSaving(true); try { const response = await fetch('/api/user/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, apiKey: newApiKey }), + body: JSON.stringify({ provider, apiKey }), }); const result = await response.json(); if (result.success) { - // Update or add the key in state setApiKeys((prev) => { const existing = prev.findIndex((k) => k.provider === provider); if (existing >= 0) { @@ -92,20 +177,101 @@ export default function ApiKeysPage(): React.ReactElement { } return [...prev, result.data]; }); - setAddingProvider(null); - setNewApiKey(''); - } else { - throw new Error(result.error || 'Failed to save API key'); + 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); - alert(err instanceof Error ? err.message : 'Failed to save API key'); + return { success: false, error: 'Network error' }; } finally { - setIsSaving(false); + setSavingProviders((prev) => { + const next = new Set(prev); + next.delete(provider); + return next; + }); } - }; + }, []); - const handleDeleteKey = async (provider: AIProvider) => { + // 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?`) ) { @@ -131,7 +297,7 @@ export default function ApiKeysPage(): React.ReactElement { } finally { setDeletingProvider(null); } - }; + }, []); const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider); const getKeyInfo = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider); @@ -190,174 +356,190 @@ export default function ApiKeysPage(): React.ReactElement { + {/* Quick Import Section */} +
+

Quick Import

+

+ Paste your .env file or environment variables below to import multiple keys at once. +

+ +