Skip to content

Commit f4a1875

Browse files
Merge pull request #8 from GreenHacker420/feature/copy-variables-between-projects-v2
feat: Add Copy Variables Between Projects
2 parents 0d9c10b + c7c3ea8 commit f4a1875

File tree

4 files changed

+303
-0
lines changed

4 files changed

+303
-0
lines changed

electron/ipc-handlers.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,117 @@ export function setupIpcHandlers() {
444444
}
445445
});
446446

447+
ipcMain.handle('envvars:copy-to-project', async (event, { sourceProjectId, targetProjectId, envVarIds, overwrite = false }) => {
448+
try {
449+
if (!masterKey) {
450+
return { success: false, error: 'Not authenticated' };
451+
}
452+
453+
// Validate projects exist
454+
const [sourceProject, targetProject] = await Promise.all([
455+
prisma.project.findUnique({ where: { id: sourceProjectId } }),
456+
prisma.project.findUnique({ where: { id: targetProjectId } }),
457+
]);
458+
459+
if (!sourceProject || !targetProject) {
460+
return { success: false, error: 'Source or target project not found' };
461+
}
462+
463+
// Get variables to copy
464+
const varsToCopy = await prisma.envVar.findMany({
465+
where: {
466+
id: { in: envVarIds },
467+
projectId: sourceProjectId,
468+
},
469+
});
470+
471+
if (varsToCopy.length === 0) {
472+
return { success: false, error: 'No variables found to copy' };
473+
}
474+
475+
// Get existing keys in target project
476+
const existingVars = await prisma.envVar.findMany({
477+
where: { projectId: targetProjectId },
478+
select: { key: true, id: true },
479+
});
480+
481+
const existingKeys = new Map(existingVars.map(v => [v.key, v.id]));
482+
483+
let copied = 0;
484+
let updated = 0;
485+
let skipped = 0;
486+
const errors = [];
487+
488+
for (const envVar of varsToCopy) {
489+
try {
490+
// Decrypt and re-encrypt (in case keys are different, though they're not in this app)
491+
const value = decrypt(envVar.encryptedValue, masterKey);
492+
const encryptedValue = encrypt(value, masterKey);
493+
494+
if (existingKeys.has(envVar.key)) {
495+
if (overwrite) {
496+
await prisma.envVar.update({
497+
where: { id: existingKeys.get(envVar.key) },
498+
data: {
499+
encryptedValue,
500+
description: envVar.description,
501+
},
502+
});
503+
504+
await prisma.auditLog.create({
505+
data: {
506+
action: 'UPDATE',
507+
entityType: 'ENVVAR',
508+
entityId: existingKeys.get(envVar.key),
509+
details: `Updated env var during copy from ${sourceProject.name}: ${envVar.key}`,
510+
},
511+
});
512+
513+
updated++;
514+
} else {
515+
skipped++;
516+
}
517+
} else {
518+
const newVar = await prisma.envVar.create({
519+
data: {
520+
projectId: targetProjectId,
521+
key: envVar.key,
522+
encryptedValue,
523+
description: envVar.description,
524+
},
525+
});
526+
527+
await prisma.auditLog.create({
528+
data: {
529+
action: 'CREATE',
530+
entityType: 'ENVVAR',
531+
entityId: newVar.id,
532+
details: `Copied env var from ${sourceProject.name}: ${envVar.key}`,
533+
},
534+
});
535+
536+
copied++;
537+
}
538+
} catch (error) {
539+
errors.push(`Failed to copy ${envVar.key}: ${error.message}`);
540+
}
541+
}
542+
543+
return {
544+
success: true,
545+
data: {
546+
copied,
547+
updated,
548+
skipped,
549+
total: varsToCopy.length,
550+
errors: errors.length > 0 ? errors : undefined,
551+
},
552+
};
553+
} catch (error) {
554+
return { success: false, error: error.message };
555+
}
556+
});
557+
447558
// Audit log handlers
448559
ipcMain.handle('audit:list', async (event, { limit = 50 }) => {
449560
try {

electron/preload.js

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

3334
// 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)