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
111 changes: 111 additions & 0 deletions electron/ipc-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,117 @@ export function setupIpcHandlers() {
}
});

ipcMain.handle('envvars:copy-to-project', async (event, { sourceProjectId, targetProjectId, envVarIds, overwrite = false }) => {
try {
if (!masterKey) {
return { success: false, error: 'Not authenticated' };
}

// Validate projects exist
const [sourceProject, targetProject] = await Promise.all([
prisma.project.findUnique({ where: { id: sourceProjectId } }),
prisma.project.findUnique({ where: { id: targetProjectId } }),
]);

if (!sourceProject || !targetProject) {
return { success: false, error: 'Source or target project not found' };
}

// Get variables to copy
const varsToCopy = await prisma.envVar.findMany({
where: {
id: { in: envVarIds },
projectId: sourceProjectId,
},
});

if (varsToCopy.length === 0) {
return { success: false, error: 'No variables found to copy' };
}

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

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

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

for (const envVar of varsToCopy) {
try {
// Decrypt and re-encrypt (in case keys are different, though they're not in this app)
const value = decrypt(envVar.encryptedValue, masterKey);
const encryptedValue = encrypt(value, masterKey);

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

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

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

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

copied++;
}
} catch (error) {
errors.push(`Failed to copy ${envVar.key}: ${error.message}`);
}
}

return {
success: true,
data: {
copied,
updated,
skipped,
total: varsToCopy.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 @@ -28,6 +28,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
delete: (id) => ipcRenderer.invoke('envvars:delete', id),
export: (data) => ipcRenderer.invoke('envvars:export', data),
import: (data) => ipcRenderer.invoke('envvars:import', data),
copyToProject: (data) => ipcRenderer.invoke('envvars:copy-to-project', data),
},

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

const { Option } = Select;

export default function CopyToProjectModal({
open,
onClose,
sourceProject,
selectedVarIds,
allProjects,
onSuccess
}) {
const [targetProjectId, setTargetProjectId] = useState(null);
const [overwrite, setOverwrite] = useState(false);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (open) {
setTargetProjectId(null);
setOverwrite(false);
}
}, [open]);

const handleCopy = async () => {
if (!targetProjectId) {
message.error('Please select a target project');
return;
}

if (selectedVarIds.length === 0) {
message.error('No variables selected to copy');
return;
}

setLoading(true);
try {
const result = await window.electronAPI.envVars.copyToProject({
sourceProjectId: sourceProject.id,
targetProjectId,
envVarIds: selectedVarIds,
overwrite,
});

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

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

message.success(messageText);

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

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

// Filter out the source project from target options
const availableProjects = allProjects.filter(p => p.id !== sourceProject.id);
const targetProject = availableProjects.find(p => p.id === targetProjectId);

return (
<Modal
title={
<div className="flex items-center gap-2">
<Copy className="w-5 h-5" />
<span>Copy Variables to Another Project</span>
</div>
}
open={open}
onCancel={onClose}
onOk={handleCopy}
okText="Copy Variables"
confirmLoading={loading}
width={550}
destroyOnClose
>
<div className="space-y-4">
<Alert
message={`Copying ${selectedVarIds.length} variable${selectedVarIds.length !== 1 ? 's' : ''} from ${sourceProject.name}`}
description="Select the target project where you want to copy these variables."
type="info"
showIcon
/>

<div>
<label className="block text-sm font-medium mb-2">
Source Project
</label>
<div className="p-3 bg-gray-800 rounded-lg">
<div className="font-medium">{sourceProject.name}</div>
{sourceProject.description && (
<div className="text-sm text-gray-400 mt-1">
{sourceProject.description}
</div>
)}
<Tag color="blue" className="mt-2">
{selectedVarIds.length} variable{selectedVarIds.length !== 1 ? 's' : ''} selected
</Tag>
</div>
</div>

<div>
<label className="block text-sm font-medium mb-2">
Target Project <span className="text-red-500">*</span>
</label>
<Select
value={targetProjectId}
onChange={setTargetProjectId}
placeholder="Select target project"
className="w-full"
showSearch
optionFilterProp="children"
>
{availableProjects.map(project => (
<Option key={project.id} value={project.id}>
<div>
<div className="font-medium">{project.name}</div>
{project.description && (
<div className="text-xs text-gray-400">
{project.description}
</div>
)}
</div>
</Option>
))}
</Select>
{availableProjects.length === 0 && (
<div className="text-sm text-gray-400 mt-2">
No other projects available. Create a new project first.
</div>
)}
</div>

{targetProject && (
<div className="p-3 bg-gray-800 rounded-lg">
<div className="text-sm text-gray-400 mb-1">Target Project</div>
<div className="font-medium">{targetProject.name}</div>
{targetProject._count && (
<Tag color="green" className="mt-2">
{targetProject._count.envVars} existing variable{targetProject._count.envVars !== 1 ? 's' : ''}
</Tag>
)}
</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 in target
</div>
</div>
<Switch
checked={overwrite}
onChange={setOverwrite}
/>
</div>

<Alert
message="Note"
description={
<Space direction="vertical" size="small">
<div>• Variables will be encrypted with the same master key</div>
<div>• Descriptions will be copied along with values</div>
<div>• All operations will be logged in audit history</div>
{!overwrite && <div>• Existing keys in target will be skipped</div>}
</Space>
}
type="warning"
showIcon
/>
</div>
</Modal>
);
}
1 change: 1 addition & 0 deletions src/components/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export default function Dashboard({ onLogout }) {
<ProjectView
project={selectedProject}
onProjectUpdate={loadProjects}
allProjects={projects}
/>
) : (
<div style={{
Expand Down
Loading