From b00ad435bd724d3c896a9cb2437c7ddff4d66edb Mon Sep 17 00:00:00 2001 From: Green Hacker Date: Sun, 12 Oct 2025 00:58:01 +0530 Subject: [PATCH] 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 --- electron/ipc-handlers.js | 111 +++++++++++++++ electron/preload.js | 1 + src/components/CopyToProjectModal.jsx | 190 ++++++++++++++++++++++++++ src/components/Dashboard.jsx | 1 + src/components/ProjectView.jsx | 37 ++++- 5 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/components/CopyToProjectModal.jsx diff --git a/electron/ipc-handlers.js b/electron/ipc-handlers.js index 9b92c1f..3d98878 100644 --- a/electron/ipc-handlers.js +++ b/electron/ipc-handlers.js @@ -301,6 +301,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 { diff --git a/electron/preload.js b/electron/preload.js index d477891..2b4bbf8 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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), + copyToProject: (data) => ipcRenderer.invoke('envvars:copy-to-project', data), }, // Audit Logs diff --git a/src/components/CopyToProjectModal.jsx b/src/components/CopyToProjectModal.jsx new file mode 100644 index 0000000..a66127d --- /dev/null +++ b/src/components/CopyToProjectModal.jsx @@ -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 ( + + + Copy Variables to Another Project + + } + open={open} + onCancel={onClose} + onOk={handleCopy} + okText="Copy Variables" + confirmLoading={loading} + width={550} + destroyOnClose + > +
+ + +
+ +
+
{sourceProject.name}
+ {sourceProject.description && ( +
+ {sourceProject.description} +
+ )} + + {selectedVarIds.length} variable{selectedVarIds.length !== 1 ? 's' : ''} selected + +
+
+ +
+ + + {availableProjects.length === 0 && ( +
+ No other projects available. Create a new project first. +
+ )} +
+ + {targetProject && ( +
+
Target Project
+
{targetProject.name}
+ {targetProject._count && ( + + {targetProject._count.envVars} existing variable{targetProject._count.envVars !== 1 ? 's' : ''} + + )} +
+ )} + +
+
+
Overwrite existing variables
+
+ Update values if keys already exist in target +
+
+ +
+ + +
• Variables will be encrypted with the same master key
+
• Descriptions will be copied along with values
+
• All operations will be logged in audit history
+ {!overwrite &&
• Existing keys in target will be skipped
} + + } + type="warning" + showIcon + /> +
+
+ ); +} diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 6de0e4a..e0c1c1a 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -138,6 +138,7 @@ export default function Dashboard({ onLogout }) { ) : (
{ loadEnvVars(); @@ -221,6 +225,14 @@ export default function ProjectView({ project, onProjectUpdate }) { )}
+ {selectedRowKeys.length > 0 && ( + + )}