From dca55f1faf00c61e3e8b6e7fe5823ab11fa9bcd9 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 3 Jan 2026 14:28:43 +1000 Subject: [PATCH] refactor: traditional API key UI with optional .env import --- .../app/dashboard/settings/api-keys/page.tsx | 343 +++++++++++------- 1 file changed, 209 insertions(+), 134 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 d531b75..904287b 100644 --- a/apps/web/src/app/dashboard/settings/api-keys/page.tsx +++ b/apps/web/src/app/dashboard/settings/api-keys/page.tsx @@ -1,6 +1,7 @@ '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'; @@ -9,13 +10,20 @@ import { useCallback, useEffect, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; interface ApiKeyInfo { - provider: string; + provider: AIProvider; keyHint: string | null; createdAt: string; updatedAt: string; } -// Map env var names to provider codes +const PROVIDER_NAMES: Record = { + OPENAI: 'OpenAI', + ANTHROPIC: 'Anthropic', + GOOGLE: 'Google', + GROQ: 'Groq', + MISTRAL: 'Mistral', +}; + const ENV_VAR_MAP: Record = { OPENAI_API_KEY: 'OPENAI', ANTHROPIC_API_KEY: 'ANTHROPIC', @@ -30,16 +38,29 @@ export default function ApiKeysPage(): React.ReactElement { const [apiKeys, setApiKeys] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + + // Individual key inputs + const [keyInputs, setKeyInputs] = useState>({ + OPENAI: '', + ANTHROPIC: '', + GOOGLE: '', + GROQ: '', + MISTRAL: '', + }); + const [savingProvider, setSavingProvider] = useState(null); + const [savedProvider, setSavedProvider] = useState(null); + const [deletingProvider, setDeletingProvider] = useState(null); + const [saveError, setSaveError] = useState(null); + + // .env import + const [showEnvImport, setShowEnvImport] = useState(false); const [envText, setEnvText] = useState(''); - const [isSaving, setIsSaving] = useState(false); - const [saveResult, setSaveResult] = useState<{ success: number; failed: number } | null>(null); - const [deletingKey, setDeletingKey] = useState(null); + const [importing, setImporting] = useState(false); const fetchApiKeys = useCallback(async () => { try { const response = await fetch('/api/user/api-keys'); const data = await response.json(); - if (data.success) { setApiKeys(data.data); } else { @@ -61,81 +82,106 @@ export default function ApiKeysPage(): React.ReactElement { fetchApiKeys(); }, [fetchApiKeys]); - const parseAndSave = useCallback(async () => { + const saveKey = useCallback(async (provider: AIProvider, apiKey: string) => { + setSavingProvider(provider); + setSavedProvider(null); + setSaveError(null); + + try { + console.log('[saveKey] Saving key for provider:', provider, 'key length:', apiKey.length); + 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(); + console.log('[saveKey] Response:', result); + + if (result.success) { + setApiKeys((prev) => { + const idx = prev.findIndex((k) => k.provider === provider); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = result.data; + return updated; + } + return [...prev, result.data]; + }); + setKeyInputs((prev) => ({ ...prev, [provider]: '' })); + setSavedProvider(provider); + setTimeout(() => setSavedProvider(null), 2000); + return true; + } else { + console.error('[saveKey] Error:', result.error); + setSaveError(result.error || 'Failed to save'); + return false; + } + } catch (err) { + console.error('[saveKey] Network error:', err); + setSaveError('Network error'); + return false; + } finally { + setSavingProvider(null); + } + }, []); + + const handleSave = useCallback((provider: AIProvider) => { + const value = keyInputs[provider]?.trim(); + if (value) { + saveKey(provider, value); + } + }, [keyInputs, saveKey]); + + const handleDelete = useCallback(async (provider: AIProvider) => { + if (!confirm(`Delete ${PROVIDER_NAMES[provider]} API key?`)) return; + + setDeletingProvider(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 { + setDeletingProvider(null); + } + }, []); + + const handleEnvImport = 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; - const match = trimmed.match(/^([A-Z_]+)\s*=\s*["']?([^"'\n]+)["']?$/); if (match) { const [, envVar, value] = match; - if (!envVar || !value) continue; - const provider = ENV_VAR_MAP[envVar]; - if (provider) { - keysToSave.push({ provider, apiKey: value.trim() }); + if (envVar && value) { + const provider = ENV_VAR_MAP[envVar]; + if (provider) { + keysToSave.push({ provider, apiKey: value.trim() }); + } } } } - if (keysToSave.length === 0) { - setSaveResult({ success: 0, failed: 0 }); - return; - } - - setIsSaving(true); - let success = 0; - let failed = 0; + if (keysToSave.length === 0) return; + setImporting(true); 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++; - } + await saveKey(provider, apiKey); } - - setSaveResult({ success, failed }); - setIsSaving(false); + setImporting(false); setEnvText(''); - fetchApiKeys(); + setShowEnvImport(false); + }, [envText, saveKey]); - 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); - } - }, - [] - ); + const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider); + const getKeyHint = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider)?.keyHint; if (isLoading) { return ( @@ -144,7 +190,8 @@ export default function ApiKeysPage(): React.ReactElement {
-
+
+
@@ -167,83 +214,111 @@ export default function ApiKeysPage(): React.ReactElement {
-
- - - -

API Keys

+
+
+ + + +

API Keys

+
+
- {/* Paste .env */} -
-