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:
parent
b87d5f627c
commit
490d76a50b
5 changed files with 405 additions and 235 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
290
apps/web/src/components/EnvVarsEditor.tsx
Normal file
290
apps/web/src/components/EnvVarsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web/src/lib/utils/env-parser.ts
Normal file
78
apps/web/src/lib/utils/env-parser.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue