Skip to content

Commit b00ad43

Browse files
feat: add copy variables between projects functionality
- Add copy-to-project IPC handler with conflict resolution - Support selective copying with row selection in table - Add CopyToProjectModal with target project selection - Handle duplicate keys with skip/overwrite options - Track all copy operations in audit logs - Show detailed feedback (copied, updated, skipped counts) - Enable bulk operations for efficient workflow - Maintain encryption during copy operations
1 parent ff870ac commit b00ad43

File tree

5 files changed

+338
-2
lines changed

5 files changed

+338
-2
lines changed

electron/ipc-handlers.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,117 @@ export function setupIpcHandlers() {
301301
}
302302
});
303303

304+
ipcMain.handle('envvars:copy-to-project', async (event, { sourceProjectId, targetProjectId, envVarIds, overwrite = false }) => {
305+
try {
306+
if (!masterKey) {
307+
return { success: false, error: 'Not authenticated' };
308+
}
309+
310+
// Validate projects exist
311+
const [sourceProject, targetProject] = await Promise.all([
312+
prisma.project.findUnique({ where: { id: sourceProjectId } }),
313+
prisma.project.findUnique({ where: { id: targetProjectId } }),
314+
]);
315+
316+
if (!sourceProject || !targetProject) {
317+
return { success: false, error: 'Source or target project not found' };
318+
}
319+
320+
// Get variables to copy
321+
const varsToCopy = await prisma.envVar.findMany({
322+
where: {
323+
id: { in: envVarIds },
324+
projectId: sourceProjectId,
325+
},
326+
});
327+
328+
if (varsToCopy.length === 0) {
329+
return { success: false, error: 'No variables found to copy' };
330+
}
331+
332+
// Get existing keys in target project
333+
const existingVars = await prisma.envVar.findMany({
334+
where: { projectId: targetProjectId },
335+
select: { key: true, id: true },
336+
});
337+
338+
const existingKeys = new Map(existingVars.map(v => [v.key, v.id]));
339+
340+
let copied = 0;
341+
let updated = 0;
342+
let skipped = 0;
343+
const errors = [];
344+
345+
for (const envVar of varsToCopy) {
346+
try {
347+
// Decrypt and re-encrypt (in case keys are different, though they're not in this app)
348+
const value = decrypt(envVar.encryptedValue, masterKey);
349+
const encryptedValue = encrypt(value, masterKey);
350+
351+
if (existingKeys.has(envVar.key)) {
352+
if (overwrite) {
353+
await prisma.envVar.update({
354+
where: { id: existingKeys.get(envVar.key) },
355+
data: {
356+
encryptedValue,
357+
description: envVar.description,
358+
},
359+
});
360+
361+
await prisma.auditLog.create({
362+
data: {
363+
action: 'UPDATE',
364+
entityType: 'ENVVAR',
365+
entityId: existingKeys.get(envVar.key),
366+
details: `Updated env var during copy from ${sourceProject.name}: ${envVar.key}`,
367+
},
368+
});
369+
370+
updated++;
371+
} else {
372+
skipped++;
373+
}
374+
} else {
375+
const newVar = await prisma.envVar.create({
376+
data: {
377+
projectId: targetProjectId,
378+
key: envVar.key,
379+
encryptedValue,
380+
description: envVar.description,
381+
},
382+
});
383+
384+
await prisma.auditLog.create({
385+
data: {
386+
action: 'CREATE',
387+
entityType: 'ENVVAR',
388+
entityId: newVar.id,
389+
details: `Copied env var from ${sourceProject.name}: ${envVar.key}`,
390+
},
391+
});
392+
393+
copied++;
394+
}
395+
} catch (error) {
396+
errors.push(`Failed to copy ${envVar.key}: ${error.message}`);
397+
}
398+
}
399+
400+
return {
401+
success: true,
402+
data: {
403+
copied,
404+
updated,
405+
skipped,
406+
total: varsToCopy.length,
407+
errors: errors.length > 0 ? errors : undefined,
408+
},
409+
};
410+
} catch (error) {
411+
return { success: false, error: error.message };
412+
}
413+
});
414+
304415
// Audit log handlers
305416
ipcMain.handle('audit:list', async (event, { limit = 50 }) => {
306417
try {

electron/preload.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
2727
update: (data) => ipcRenderer.invoke('envvars:update', data),
2828
delete: (id) => ipcRenderer.invoke('envvars:delete', id),
2929
export: (data) => ipcRenderer.invoke('envvars:export', data),
30+
copyToProject: (data) => ipcRenderer.invoke('envvars:copy-to-project', data),
3031
},
3132

3233
// Audit Logs
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { useState, useEffect } from 'react';
2+
import { Modal, Select, Switch, message, Alert, Tag, Space } from 'antd';
3+
import { Copy } from 'lucide-react';
4+
5+
const { Option } = Select;
6+
7+
export default function CopyToProjectModal({
8+
open,
9+
onClose,
10+
sourceProject,
11+
selectedVarIds,
12+
allProjects,
13+
onSuccess
14+
}) {
15+
const [targetProjectId, setTargetProjectId] = useState(null);
16+
const [overwrite, setOverwrite] = useState(false);
17+
const [loading, setLoading] = useState(false);
18+
19+
useEffect(() => {
20+
if (open) {
21+
setTargetProjectId(null);
22+
setOverwrite(false);
23+
}
24+
}, [open]);
25+
26+
const handleCopy = async () => {
27+
if (!targetProjectId) {
28+
message.error('Please select a target project');
29+
return;
30+
}
31+
32+
if (selectedVarIds.length === 0) {
33+
message.error('No variables selected to copy');
34+
return;
35+
}
36+
37+
setLoading(true);
38+
try {
39+
const result = await window.electronAPI.envVars.copyToProject({
40+
sourceProjectId: sourceProject.id,
41+
targetProjectId,
42+
envVarIds: selectedVarIds,
43+
overwrite,
44+
});
45+
46+
if (result.success) {
47+
const { copied, updated, skipped, errors } = result.data;
48+
49+
let messageText = `Copy complete: ${copied} copied`;
50+
if (updated > 0) messageText += `, ${updated} updated`;
51+
if (skipped > 0) messageText += `, ${skipped} skipped`;
52+
53+
message.success(messageText);
54+
55+
if (errors && errors.length > 0) {
56+
console.error('Copy errors:', errors);
57+
message.warning(`${errors.length} variables had errors`);
58+
}
59+
60+
onSuccess();
61+
onClose();
62+
} else {
63+
message.error(result.error || 'Failed to copy variables');
64+
}
65+
} catch (error) {
66+
message.error('Failed to copy: ' + error.message);
67+
} finally {
68+
setLoading(false);
69+
}
70+
};
71+
72+
// Filter out the source project from target options
73+
const availableProjects = allProjects.filter(p => p.id !== sourceProject.id);
74+
const targetProject = availableProjects.find(p => p.id === targetProjectId);
75+
76+
return (
77+
<Modal
78+
title={
79+
<div className="flex items-center gap-2">
80+
<Copy className="w-5 h-5" />
81+
<span>Copy Variables to Another Project</span>
82+
</div>
83+
}
84+
open={open}
85+
onCancel={onClose}
86+
onOk={handleCopy}
87+
okText="Copy Variables"
88+
confirmLoading={loading}
89+
width={550}
90+
destroyOnClose
91+
>
92+
<div className="space-y-4">
93+
<Alert
94+
message={`Copying ${selectedVarIds.length} variable${selectedVarIds.length !== 1 ? 's' : ''} from ${sourceProject.name}`}
95+
description="Select the target project where you want to copy these variables."
96+
type="info"
97+
showIcon
98+
/>
99+
100+
<div>
101+
<label className="block text-sm font-medium mb-2">
102+
Source Project
103+
</label>
104+
<div className="p-3 bg-gray-800 rounded-lg">
105+
<div className="font-medium">{sourceProject.name}</div>
106+
{sourceProject.description && (
107+
<div className="text-sm text-gray-400 mt-1">
108+
{sourceProject.description}
109+
</div>
110+
)}
111+
<Tag color="blue" className="mt-2">
112+
{selectedVarIds.length} variable{selectedVarIds.length !== 1 ? 's' : ''} selected
113+
</Tag>
114+
</div>
115+
</div>
116+
117+
<div>
118+
<label className="block text-sm font-medium mb-2">
119+
Target Project <span className="text-red-500">*</span>
120+
</label>
121+
<Select
122+
value={targetProjectId}
123+
onChange={setTargetProjectId}
124+
placeholder="Select target project"
125+
className="w-full"
126+
showSearch
127+
optionFilterProp="children"
128+
>
129+
{availableProjects.map(project => (
130+
<Option key={project.id} value={project.id}>
131+
<div>
132+
<div className="font-medium">{project.name}</div>
133+
{project.description && (
134+
<div className="text-xs text-gray-400">
135+
{project.description}
136+
</div>
137+
)}
138+
</div>
139+
</Option>
140+
))}
141+
</Select>
142+
{availableProjects.length === 0 && (
143+
<div className="text-sm text-gray-400 mt-2">
144+
No other projects available. Create a new project first.
145+
</div>
146+
)}
147+
</div>
148+
149+
{targetProject && (
150+
<div className="p-3 bg-gray-800 rounded-lg">
151+
<div className="text-sm text-gray-400 mb-1">Target Project</div>
152+
<div className="font-medium">{targetProject.name}</div>
153+
{targetProject._count && (
154+
<Tag color="green" className="mt-2">
155+
{targetProject._count.envVars} existing variable{targetProject._count.envVars !== 1 ? 's' : ''}
156+
</Tag>
157+
)}
158+
</div>
159+
)}
160+
161+
<div className="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
162+
<div>
163+
<div className="font-medium">Overwrite existing variables</div>
164+
<div className="text-sm text-gray-400">
165+
Update values if keys already exist in target
166+
</div>
167+
</div>
168+
<Switch
169+
checked={overwrite}
170+
onChange={setOverwrite}
171+
/>
172+
</div>
173+
174+
<Alert
175+
message="Note"
176+
description={
177+
<Space direction="vertical" size="small">
178+
<div>• Variables will be encrypted with the same master key</div>
179+
<div>• Descriptions will be copied along with values</div>
180+
<div>• All operations will be logged in audit history</div>
181+
{!overwrite && <div>• Existing keys in target will be skipped</div>}
182+
</Space>
183+
}
184+
type="warning"
185+
showIcon
186+
/>
187+
</div>
188+
</Modal>
189+
);
190+
}

src/components/Dashboard.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export default function Dashboard({ onLogout }) {
138138
<ProjectView
139139
project={selectedProject}
140140
onProjectUpdate={loadProjects}
141+
allProjects={projects}
141142
/>
142143
) : (
143144
<div style={{

0 commit comments

Comments
 (0)