feat: add reusable EnvVarsEditor component with paste .env feature

- Create EnvVarsEditor component for editing key-value env vars
- Add paste .env snippet feature with preview of parsed variables
- Refactor agent and collection pages to use the new component
- Extract shared parseEnvString utility for consistent .env parsing
- Update API keys settings page to use shared parser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-12 05:04:03 +10:00
parent b87d5f627c
commit 490d76a50b
5 changed files with 405 additions and 235 deletions

View file

@ -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<Array<{ key: string; value: string }>>([]);
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
// Environment variables state (stored as object for the EnvVarsEditor component)
const [envVars, setEnvVars] = useState<Record<string, string> | 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<string, string>);
} 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<string, string> = {};
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 {
</div>
{/* Environment Variables */}
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-foreground">Environment Variables</h3>
<p className="text-xs text-foreground-tertiary mt-0.5">
Passed to tools at runtime. Agent vars override collection vars.
</p>
</div>
</div>
{/* Existing env vars */}
{envVars.length > 0 && (
<div className="space-y-2 mb-3">
{envVars.map((env, index) => (
<div key={`env-${env.key || index}`} className="flex items-center gap-2">
<input
type="text"
value={env.key}
onChange={(e) => {
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"
/>
<input
type="password"
value={env.value}
onChange={(e) => {
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"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEnvVars(envVars.filter((_, i) => i !== index));
}}
title="Remove"
>
<Icon icon="trash" size="xs" />
</Button>
</div>
))}
</div>
)}
{/* Add new env var */}
<div className="flex items-center gap-2">
<input
type="text"
value={newEnvKey}
onChange={(e) => 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"
/>
<input
type="text"
value={newEnvValue}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (newEnvKey.trim()) {
setEnvVars([...envVars, { key: newEnvKey.trim(), value: newEnvValue }]);
setNewEnvKey('');
setNewEnvValue('');
}
}}
disabled={!newEnvKey.trim()}
>
<Icon icon="plus" size="xs" className="mr-1" />
Add
</Button>
</div>
</div>
<EnvVarsEditor
value={envVars}
onChange={setEnvVars}
title="Environment Variables"
description="Passed to tools at runtime. Agent vars override collection vars."
disabled={isSaving}
className="pt-4 border-t border-border"
/>
<div className="flex items-center justify-end gap-2 pt-4">
<Button
@ -1051,17 +962,10 @@ export default function AgentDetailPage(): React.ReactElement {
}
// Reset env vars to agent's current value
if (agent.envVars && typeof agent.envVars === 'object') {
setEnvVars(
Object.entries(agent.envVars).map(([key, value]) => ({
key,
value: String(value),
}))
);
setEnvVars(agent.envVars as Record<string, string>);
} else {
setEnvVars([]);
setEnvVars(null);
}
setNewEnvKey('');
setNewEnvValue('');
}}
>
Cancel

View file

@ -11,6 +11,7 @@ import { AddToolSearch } from '~/components/collections/AddToolSearch';
import { CollectionForm } from '~/components/collections/CollectionForm';
import { CollectionToolList } from '~/components/collections/CollectionToolList';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
import { EnvVarsEditor } from '~/components/EnvVarsEditor';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
function McpUrlSection({ collectionId }: { collectionId: string }) {
@ -191,10 +192,8 @@ export default function CollectionDetailPage(): React.ReactElement {
const [isDeleting, setIsDeleting] = useState(false);
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
// Environment variables state
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
// Environment variables state (stored as object for the EnvVarsEditor component)
const [envVars, setEnvVars] = useState<Record<string, string> | null>(null);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Fetch callback with error handling
const fetchCollection = useCallback(async () => {
@ -216,14 +215,9 @@ export default function CollectionDetailPage(): React.ReactElement {
}
// Initialize env vars from collection 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<string, string>);
} else {
setEnvVars([]);
setEnvVars(null);
}
} else {
if (data.error?.code === 'UNAUTHORIZED') {
@ -270,14 +264,8 @@ export default function CollectionDetailPage(): React.ReactElement {
updatePayload.executorConfig = null;
}
// Add env vars - convert array to object
const envVarsObject: Record<string, string> = {};
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/collections/${collectionId}`, {
@ -504,91 +492,14 @@ export default function CollectionDetailPage(): React.ReactElement {
</div>
{/* Environment Variables */}
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-foreground">Environment Variables</h3>
<p className="text-xs text-foreground-tertiary mt-0.5">
Passed to tools at runtime. Agent vars override collection vars.
</p>
</div>
</div>
{/* Existing env vars */}
{envVars.length > 0 && (
<div className="space-y-2 mb-3">
{envVars.map((env, index) => (
<div key={`env-${env.key || index}`} className="flex items-center gap-2">
<input
type="text"
value={env.key}
onChange={(e) => {
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"
/>
<input
type="password"
value={env.value}
onChange={(e) => {
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"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEnvVars(envVars.filter((_, i) => i !== index));
}}
title="Remove"
>
<Icon icon="trash" size="xs" />
</Button>
</div>
))}
</div>
)}
{/* Add new env var */}
<div className="flex items-center gap-2">
<input
type="text"
value={newEnvKey}
onChange={(e) => 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"
/>
<input
type="text"
value={newEnvValue}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (newEnvKey.trim()) {
setEnvVars([...envVars, { key: newEnvKey.trim(), value: newEnvValue }]);
setNewEnvKey('');
setNewEnvValue('');
}
}}
disabled={!newEnvKey.trim()}
>
<Icon icon="plus" size="xs" className="mr-1" />
Add
</Button>
</div>
</div>
<EnvVarsEditor
value={envVars}
onChange={setEnvVars}
title="Environment Variables"
description="Passed to tools at runtime. Agent vars override collection vars."
disabled={isUpdating}
className="mt-6 pt-6 border-t border-border"
/>
</div>
)}

View file

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

View file

@ -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<string, string> */
value: Record<string, string> | null | undefined;
/** Called when env vars change */
onChange: (value: Record<string, string> | 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<EnvVar[]>([]);
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<string | null>(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<string, string> = {};
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 (
<div className={className}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{description && <p className="text-xs text-foreground-tertiary mt-0.5">{description}</p>}
</div>
{showPasteEnv && (
<Button
size="sm"
variant="ghost"
onClick={() => setShowPasteModal(!showPasteModal)}
disabled={disabled}
title="Paste .env snippet"
>
<Icon icon="copy" size="xs" className="mr-1" />
Paste .env
</Button>
)}
</div>
{/* Paste .env modal/section */}
{showPasteModal && (
<div className="mb-4 p-3 bg-surface-secondary rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">Paste .env snippet</span>
<Button size="sm" variant="ghost" onClick={() => setShowPasteModal(false)}>
<Icon icon="x" size="xs" />
</Button>
</div>
<textarea
value={pasteContent}
onChange={(e) => setPasteContent(e.target.value)}
placeholder={`# Paste your .env content here\nAPI_KEY=your-api-key\nDATABASE_URL="postgres://..."\n`}
className="w-full h-32 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-xs focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
disabled={disabled}
/>
{parsedPreview.length > 0 && (
<div className="mt-2 text-xs text-foreground-secondary">
<span className="font-medium">Preview:</span> {parsedPreview.length} variable
{parsedPreview.length !== 1 ? 's' : ''} found
<span className="text-foreground-tertiary ml-1">
({parsedPreview.map((e) => e.key).join(', ')})
</span>
</div>
)}
<div className="flex justify-end mt-2">
<Button
size="sm"
variant="default"
onClick={handlePasteEnv}
disabled={parsedPreview.length === 0}
>
<Icon icon="plus" size="xs" className="mr-1" />
Add {parsedPreview.length} Variable{parsedPreview.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
)}
{/* Existing env vars */}
{envVars.length > 0 && (
<div className="space-y-2 mb-3">
{envVars.map((env, index) => (
<div key={`env-${env.key || index}`} className="flex items-center gap-2">
<input
type="text"
value={env.key}
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
placeholder="KEY"
className={inputClassName}
disabled={disabled}
/>
<input
type="password"
value={env.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
placeholder="value"
className={inputClassName}
disabled={disabled}
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeEnvVar(index)}
title="Remove"
disabled={disabled}
>
<Icon icon="trash" size="xs" />
</Button>
</div>
))}
</div>
)}
{/* Add new env var */}
<div className="flex items-center gap-2">
<input
type="text"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder={keyPlaceholder}
className={inputClassName}
disabled={disabled}
onKeyDown={(e) => {
if (e.key === 'Enter' && newEnvKey.trim()) {
addEnvVar();
}
}}
/>
<input
type="text"
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
placeholder={valuePlaceholder}
className={inputClassName}
disabled={disabled}
onKeyDown={(e) => {
if (e.key === 'Enter' && newEnvKey.trim()) {
addEnvVar();
}
}}
/>
<Button
size="sm"
variant="secondary"
onClick={addEnvVar}
disabled={disabled || !newEnvKey.trim()}
>
<Icon icon="plus" size="xs" className="mr-1" />
Add
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
/**
* Parse a .env file format string into key-value pairs
*
* Supports:
* - KEY=value
* - KEY="quoted value"
* - KEY='single quoted'
* - # comments
* - Empty lines
* - Multiline values with quotes
*/
export function parseEnvString(input: string): Array<{ key: string; value: string }> {
const lines = input.split('\n');
const result: Array<{ key: string; value: string }> = [];
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Find the first = sign
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) {
continue;
}
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
// Remove surrounding quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (key) {
result.push({ key: key.toUpperCase(), value });
}
}
return result;
}
/**
* Convert parsed env vars to a Record object
*/
export function envArrayToRecord(
vars: Array<{ key: string; value: string }>
): Record<string, string> {
const record: Record<string, string> = {};
for (const { key, value } of vars) {
const trimmedKey = key.trim();
if (trimmedKey) {
record[trimmedKey] = value;
}
}
return record;
}
/**
* Convert a Record to an array of env vars
*/
export function envRecordToArray(
record: Record<string, string> | null | undefined
): Array<{ key: string; value: string }> {
if (!record || typeof record !== 'object') {
return [];
}
return Object.entries(record).map(([key, value]) => ({
key,
value: String(value),
}));
}