Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions electron/ipc-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,149 @@ export function setupIpcHandlers() {
}
});

ipcMain.handle('envvars:import', async (event, { projectId, content, format, overwrite = false }) => {
try {
if (!masterKey) {
return { success: false, error: 'Not authenticated' };
}

let parsedVars = [];

if (format === 'env') {
// Parse .env format
const lines = content.split('\n');
let currentComment = '';

for (const line of lines) {
const trimmed = line.trim();

// Skip empty lines
if (!trimmed) {
currentComment = '';
continue;
}

// Capture comments
if (trimmed.startsWith('#')) {
currentComment = trimmed.substring(1).trim();
continue;
}

// Parse key=value
const equalIndex = trimmed.indexOf('=');
if (equalIndex > 0) {
const key = trimmed.substring(0, equalIndex).trim();
let value = trimmed.substring(equalIndex + 1).trim();

// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1);
}

parsedVars.push({
key,
value,
description: currentComment || null,
});
currentComment = '';
}
}
} else if (format === 'json') {
// Parse JSON format
const obj = JSON.parse(content);
parsedVars = Object.entries(obj).map(([key, value]) => ({
key,
value: String(value),
description: null,
}));
}

if (parsedVars.length === 0) {
return { success: false, error: 'No valid environment variables found' };
}

// Get existing keys
const existingVars = await prisma.envVar.findMany({
where: { projectId },
select: { key: true, id: true },
});

const existingKeys = new Map(existingVars.map(v => [v.key, v.id]));

let imported = 0;
let updated = 0;
let skipped = 0;
const errors = [];

for (const { key, value, description } of parsedVars) {
try {
const encryptedValue = encrypt(value, masterKey);

if (existingKeys.has(key)) {
if (overwrite) {
await prisma.envVar.update({
where: { id: existingKeys.get(key) },
data: {
encryptedValue,
description: description || undefined,
},
});

await prisma.auditLog.create({
data: {
action: 'UPDATE',
entityType: 'ENVVAR',
entityId: existingKeys.get(key),
details: `Updated env var during import: ${key}`,
},
});

updated++;
} else {
skipped++;
}
} else {
const envVar = await prisma.envVar.create({
data: {
projectId,
key,
encryptedValue,
description,
},
});

await prisma.auditLog.create({
data: {
action: 'CREATE',
entityType: 'ENVVAR',
entityId: envVar.id,
details: `Imported env var: ${key}`,
},
});

imported++;
}
} catch (error) {
errors.push(`Failed to import ${key}: ${error.message}`);
}
}

return {
success: true,
data: {
imported,
updated,
skipped,
total: parsedVars.length,
errors: errors.length > 0 ? errors : undefined,
}
};
} catch (error) {
return { success: false, error: error.message };
}
});

// Audit log handlers
ipcMain.handle('audit:list', async (event, { limit = 50 }) => {
try {
Expand Down
1 change: 1 addition & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
update: (data) => ipcRenderer.invoke('envvars:update', data),
delete: (id) => ipcRenderer.invoke('envvars:delete', id),
export: (data) => ipcRenderer.invoke('envvars:export', data),
import: (data) => ipcRenderer.invoke('envvars:import', data),
},

// Audit Logs
Expand Down
173 changes: 173 additions & 0 deletions src/components/ImportEnvModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useState } from 'react';
import { Modal, Input, Select, Switch, message, Alert } from 'antd';
import { Upload } from 'lucide-react';

const { TextArea } = Input;
const { Option } = Select;

export default function ImportEnvModal({ open, onClose, projectId, onSuccess }) {
const [content, setContent] = useState('');
const [format, setFormat] = useState('env');
const [overwrite, setOverwrite] = useState(false);
const [loading, setLoading] = useState(false);

const handleFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;

const reader = new FileReader();
reader.onload = (event) => {
setContent(event.target.result);

// Auto-detect format
if (file.name.endsWith('.json')) {
setFormat('json');
} else {
setFormat('env');
}
};
reader.readAsText(file);
};

const handleImport = async () => {
if (!content.trim()) {
message.error('Please provide content to import');
return;
}

setLoading(true);
try {
const result = await window.electronAPI.envVars.import({
projectId,
content,
format,
overwrite,
});

if (result.success) {
const { imported, updated, skipped, total, errors } = result.data;

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable total.

let messageText = `Import complete: ${imported} imported`;
if (updated > 0) messageText += `, ${updated} updated`;
if (skipped > 0) messageText += `, ${skipped} skipped`;

message.success(messageText);

if (errors && errors.length > 0) {
console.error('Import errors:', errors);
message.warning(`${errors.length} variables had errors`);
}

onSuccess();
handleClose();
} else {
message.error(result.error || 'Failed to import');
}
} catch (error) {
message.error('Failed to import: ' + error.message);
} finally {
setLoading(false);
}
};

const handleClose = () => {
setContent('');
setFormat('env');
setOverwrite(false);
onClose();
};

return (
<Modal
title={
<div className="flex items-center gap-2">
<Upload className="w-5 h-5" />
<span>Import Environment Variables</span>
</div>
}
open={open}
onCancel={handleClose}
onOk={handleImport}
okText="Import"
confirmLoading={loading}
width={600}
destroyOnClose
>
<div className="space-y-4">
<Alert
message="Import your existing .env or JSON files"
description="Paste the content below or upload a file. Comments in .env files will be preserved as descriptions."
type="info"
showIcon
/>

<div>
<label className="block text-sm font-medium mb-2">
Upload File (Optional)
</label>
<input
type="file"
accept=".env,.json,.txt"
onChange={handleFileUpload}
className="block w-full text-sm text-gray-400
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-blue-600 file:text-white
hover:file:bg-blue-700
file:cursor-pointer cursor-pointer"
/>
</div>

<div>
<label className="block text-sm font-medium mb-2">Format</label>
<Select
value={format}
onChange={setFormat}
className="w-full"
>
<Option value="env">.env format (KEY=value)</Option>
<Option value="json">JSON format</Option>
</Select>
</div>

<div>
<label className="block text-sm font-medium mb-2">Content</label>
<TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={
format === 'env'
? '# Database configuration\nDB_HOST=localhost\nDB_PORT=5432\nDB_NAME=myapp'
: '{\n "DB_HOST": "localhost",\n "DB_PORT": "5432",\n "DB_NAME": "myapp"\n}'
}
rows={10}
className="font-mono text-sm"
/>
</div>

<div className="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
<div>
<div className="font-medium">Overwrite existing variables</div>
<div className="text-sm text-gray-400">
Update values if keys already exist
</div>
</div>
<Switch
checked={overwrite}
onChange={setOverwrite}
/>
</div>

{format === 'env' && (
<Alert
message="Tip"
description="Comments above variables (lines starting with #) will be imported as descriptions."
type="success"
showIcon
/>
)}
</div>
</Modal>
);
}
21 changes: 20 additions & 1 deletion src/components/ProjectView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import {
EyeOutlined,
EyeInvisibleOutlined,
CopyOutlined,
CheckOutlined
CheckOutlined,
UploadOutlined
} from '@ant-design/icons';
import EnvVarModal from './EnvVarModal';
import ImportEnvModal from './ImportEnvModal';

const { Title, Text } = Typography;

export default function ProjectView({ project, onProjectUpdate }) {
const [envVars, setEnvVars] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [editingVar, setEditingVar] = useState(null);
const [visibleValues, setVisibleValues] = useState({});
const [copiedId, setCopiedId] = useState(null);
Expand Down Expand Up @@ -221,6 +224,12 @@ export default function ProjectView({ project, onProjectUpdate }) {
)}
</div>
<Space>
<Button
icon={<UploadOutlined />}
onClick={() => setShowImportModal(true)}
>
Import
</Button>
<Button
icon={<DownloadOutlined />}
onClick={() => handleExport('env')}
Expand Down Expand Up @@ -289,6 +298,16 @@ export default function ProjectView({ project, onProjectUpdate }) {
onUpdate={handleUpdate}
editingVar={editingVar}
/>

<ImportEnvModal
open={showImportModal}
onClose={() => setShowImportModal(false)}
projectId={project.id}
onSuccess={async () => {
await loadEnvVars();
await onProjectUpdate();
}}
/>
</div>
);
}
Loading