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 (
+