diff --git a/apps/web/src/app/dashboard/agents/[id]/page.tsx b/apps/web/src/app/dashboard/agents/[id]/page.tsx index f40d27c..a3c8132 100644 --- a/apps/web/src/app/dashboard/agents/[id]/page.tsx +++ b/apps/web/src/app/dashboard/agents/[id]/page.tsx @@ -21,6 +21,7 @@ import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; +import { EnvVarsEditor } from '~/components/EnvVarsEditor'; import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel'; interface Agent { @@ -303,10 +304,8 @@ export default function AgentDetailPage(): React.ReactElement { isPublic: true, }); - // Environment variables state (separate from form to handle key-value pairs) - const [envVars, setEnvVars] = useState>([]); - const [newEnvKey, setNewEnvKey] = useState(''); - const [newEnvValue, setNewEnvValue] = useState(''); + // Environment variables state (stored as object for the EnvVarsEditor component) + const [envVars, setEnvVars] = useState | null>(null); // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple state initialization from fetched data const fetchAgent = useCallback(async () => { @@ -340,14 +339,9 @@ export default function AgentDetailPage(): React.ReactElement { } // Initialize env vars from agent data if (data.data.envVars && typeof data.data.envVars === 'object') { - setEnvVars( - Object.entries(data.data.envVars).map(([key, value]) => ({ - key, - value: String(value), - })) - ); + setEnvVars(data.data.envVars as Record); } else { - setEnvVars([]); + setEnvVars(null); } } else { if (response.status === 401) { @@ -638,14 +632,8 @@ export default function AgentDetailPage(): React.ReactElement { updatePayload.executorConfig = null; } - // Add env vars - convert array back to object - const envVarsObject: Record = {}; - for (const { key, value } of envVars) { - if (key.trim()) { - envVarsObject[key.trim()] = value; - } - } - updatePayload.envVars = Object.keys(envVarsObject).length > 0 ? envVarsObject : null; + // Add env vars + updatePayload.envVars = envVars; try { const response = await fetch(`/api/agents/${agentId}`, { @@ -936,91 +924,14 @@ export default function AgentDetailPage(): React.ReactElement { {/* Environment Variables */} -
-
-
-

Environment Variables

-

- Passed to tools at runtime. Agent vars override collection vars. -

-
-
- - {/* Existing env vars */} - {envVars.length > 0 && ( -
- {envVars.map((env, index) => ( -
- { - const updated = [...envVars]; - updated[index] = { key: e.target.value, value: env.value }; - setEnvVars(updated); - }} - placeholder="KEY" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - { - const updated = [...envVars]; - updated[index] = { key: env.key, value: e.target.value }; - setEnvVars(updated); - }} - placeholder="value" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - -
- ))} -
- )} - - {/* Add new env var */} -
- setNewEnvKey(e.target.value.toUpperCase())} - placeholder="NEW_KEY" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - setNewEnvValue(e.target.value)} - placeholder="value" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - -
-
+
{/* Environment Variables */} -
-
-
-

Environment Variables

-

- Passed to tools at runtime. Agent vars override collection vars. -

-
-
- - {/* Existing env vars */} - {envVars.length > 0 && ( -
- {envVars.map((env, index) => ( -
- { - const updated = [...envVars]; - updated[index] = { key: e.target.value, value: env.value }; - setEnvVars(updated); - }} - placeholder="KEY" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - { - const updated = [...envVars]; - updated[index] = { key: env.key, value: e.target.value }; - setEnvVars(updated); - }} - placeholder="value" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - -
- ))} -
- )} - - {/* Add new env var */} -
- setNewEnvKey(e.target.value.toUpperCase())} - placeholder="NEW_KEY" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - setNewEnvValue(e.target.value)} - placeholder="value" - className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" - /> - -
-
+ )} 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 4530b15..9f03f25 100644 --- a/apps/web/src/app/dashboard/settings/api-keys/page.tsx +++ b/apps/web/src/app/dashboard/settings/api-keys/page.tsx @@ -14,6 +14,7 @@ import { import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; +import { parseEnvString } from '~/lib/utils/env-parser'; interface ApiKeyInfo { id: string; @@ -128,26 +129,12 @@ export default function ApiKeysPage(): React.ReactElement { }, []); const handleImport = useCallback(async () => { - const lines = envText.split('\n'); - const keysToSave: { keyName: string; keyValue: string }[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*["']?(.+?)["']?$/); - if (match) { - const [, keyName, keyValue] = match; - if (keyName && keyValue) { - keysToSave.push({ keyName, keyValue: keyValue.trim() }); - } - } - } - - if (keysToSave.length === 0) return; + const parsed = parseEnvString(envText); + if (parsed.length === 0) return; setImporting(true); - for (const { keyName, keyValue } of keysToSave) { - await saveKey(keyName, keyValue); + for (const { key, value } of parsed) { + await saveKey(key, value); } setImporting(false); setEnvText(''); diff --git a/apps/web/src/components/EnvVarsEditor.tsx b/apps/web/src/components/EnvVarsEditor.tsx new file mode 100644 index 0000000..95e52d6 --- /dev/null +++ b/apps/web/src/components/EnvVarsEditor.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { parseEnvString } from '~/lib/utils/env-parser'; + +interface EnvVar { + key: string; + value: string; +} + +export interface EnvVarsEditorProps { + /** Current env vars as Record */ + value: Record | null | undefined; + /** Called when env vars change */ + onChange: (value: Record | null) => void; + /** Title shown above the editor */ + title?: string; + /** Description shown below the title */ + description?: string; + /** Placeholder for new key input */ + keyPlaceholder?: string; + /** Placeholder for new value input */ + valuePlaceholder?: string; + /** Whether the editor is disabled */ + disabled?: boolean; + /** Show the paste .env snippet feature */ + showPasteEnv?: boolean; + /** className for the container */ + className?: string; +} + +/** + * Reusable component for editing environment variables + * Used for agent env vars, collection env vars, API keys, and user-level env vars + */ +export function EnvVarsEditor({ + value, + onChange, + title = 'Environment Variables', + description, + keyPlaceholder = 'NEW_KEY', + valuePlaceholder = 'value', + disabled = false, + showPasteEnv = true, + className = '', +}: EnvVarsEditorProps) { + // Internal array state for editing + const [envVars, setEnvVars] = useState([]); + const [newEnvKey, setNewEnvKey] = useState(''); + const [newEnvValue, setNewEnvValue] = useState(''); + const [showPasteModal, setShowPasteModal] = useState(false); + const [pasteContent, setPasteContent] = useState(''); + + // Sync from prop to internal state (only when value actually changes) + // Using a ref to track the serialized value to prevent unnecessary state updates + const valueRef = useRef(null); + const serializedValue = value ? JSON.stringify(value) : null; + + if (serializedValue !== valueRef.current) { + valueRef.current = serializedValue; + const newEnvVars = + value && typeof value === 'object' + ? Object.entries(value).map(([key, val]) => ({ key, value: val })) + : []; + // Only update if the arrays are actually different + if (JSON.stringify(newEnvVars) !== JSON.stringify(envVars)) { + setEnvVars(newEnvVars); + } + } + + // Convert array to record and call onChange + const emitChange = useCallback( + (vars: EnvVar[]) => { + const record: Record = {}; + for (const { key, value: val } of vars) { + const trimmedKey = key.trim(); + if (trimmedKey) { + record[trimmedKey] = val; + } + } + onChange(Object.keys(record).length > 0 ? record : null); + }, + [onChange] + ); + + // Update a single env var + const updateEnvVar = useCallback( + (index: number, field: 'key' | 'value', newValue: string) => { + const updated = [...envVars]; + const current = updated[index]; + if (!current) return; + updated[index] = { + key: field === 'key' ? newValue.toUpperCase() : current.key, + value: field === 'value' ? newValue : current.value, + }; + setEnvVars(updated); + emitChange(updated); + }, + [envVars, emitChange] + ); + + // Remove an env var + const removeEnvVar = useCallback( + (index: number) => { + const updated = envVars.filter((_, i) => i !== index); + setEnvVars(updated); + emitChange(updated); + }, + [envVars, emitChange] + ); + + // Add a new env var + const addEnvVar = useCallback(() => { + if (!newEnvKey.trim()) return; + + const updated = [...envVars, { key: newEnvKey.trim().toUpperCase(), value: newEnvValue }]; + setEnvVars(updated); + emitChange(updated); + setNewEnvKey(''); + setNewEnvValue(''); + }, [envVars, newEnvKey, newEnvValue, emitChange]); + + // Handle pasting .env content + const handlePasteEnv = useCallback(() => { + const parsed = parseEnvString(pasteContent); + if (parsed.length === 0) { + return; + } + + // Merge with existing, new values override existing keys + const existingMap = new Map(envVars.map((e) => [e.key, e.value])); + for (const { key, value: val } of parsed) { + existingMap.set(key, val); + } + + const updated = Array.from(existingMap.entries()).map(([key, val]) => ({ key, value: val })); + setEnvVars(updated); + emitChange(updated); + setPasteContent(''); + setShowPasteModal(false); + }, [pasteContent, envVars, emitChange]); + + // Preview of parsed env vars + const parsedPreview = useMemo(() => { + if (!pasteContent.trim()) return []; + return parseEnvString(pasteContent); + }, [pasteContent]); + + const inputClassName = + 'flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary disabled:opacity-50 disabled:cursor-not-allowed'; + + return ( +
+ {/* Header */} +
+
+

{title}

+ {description &&

{description}

} +
+ {showPasteEnv && ( + + )} +
+ + {/* Paste .env modal/section */} + {showPasteModal && ( +
+
+ Paste .env snippet + +
+