diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx index 79673bfbc..e37774a72 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx @@ -2,7 +2,7 @@ import { createElement, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { Plus, Search, Share2, Trash2 } from 'lucide-react' +import { AlertTriangle, Plus, Search, Share2, Trash2 } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -297,7 +297,7 @@ export function CredentialsManager() { [createSecretScope] ) const selectedExistingEnvCredential = useMemo(() => { - if (createType !== 'secret') return null + if (createType !== 'secret' || createSecretInputMode !== 'single') return null const envKey = normalizeEnvKeyInput(createEnvKey) if (!envKey) return null return ( @@ -306,7 +306,31 @@ export function CredentialsManager() { row.type === createSecretType && (row.envKey || '').toLowerCase() === envKey.toLowerCase() ) ?? null ) - }, [credentials, createEnvKey, createSecretType, createType]) + }, [credentials, createEnvKey, createSecretType, createType, createSecretInputMode]) + + const crossScopeEnvConflict = useMemo(() => { + if (createType !== 'secret' || createSecretInputMode !== 'single') return null + if (createSecretScope !== 'personal') return null + const envKey = normalizeEnvKeyInput(createEnvKey) + if (!envKey) return null + return ( + credentials.find( + (row) => + row.type === 'env_workspace' && (row.envKey || '').toLowerCase() === envKey.toLowerCase() + ) ?? null + ) + }, [credentials, createEnvKey, createSecretScope, createType, createSecretInputMode]) + + const existingOAuthDisplayName = useMemo(() => { + if (createType !== 'oauth') return null + const name = createDisplayName.trim() + if (!name) return null + return ( + credentials.find( + (row) => row.type === 'oauth' && row.displayName.toLowerCase() === name.toLowerCase() + ) ?? null + ) + }, [credentials, createDisplayName, createType]) const selectedEnvCurrentValue = useMemo(() => { if (!selectedCredential || selectedCredential.type === 'oauth') return '' const envKey = selectedCredential.envKey || '' @@ -1309,6 +1333,20 @@ export function CredentialsManager() { /> + {existingOAuthDisplayName && ( +
+
+ +

+ A credential named{' '} + + {existingOAuthDisplayName.displayName} + {' '} + already exists. +

+
+
+ )} ) : (
@@ -1411,28 +1449,29 @@ export function CredentialsManager() {
{selectedExistingEnvCredential && ( -
-

- This secret key already maps to credential{' '} - - {selectedExistingEnvCredential.displayName} - - . -

-

- Create will update the secret value and reuse the existing credential. -

- +
+
+ +

+ A secret with key{' '} + + {selectedExistingEnvCredential.displayName} + {' '} + already exists. +

+
+
+ )} + {!selectedExistingEnvCredential && crossScopeEnvConflict && ( +
+
+ +

+ A workspace secret with key{' '} + {crossScopeEnvConflict.envKey}{' '} + already exists. Workspace secrets take precedence at runtime. +

+
)} @@ -1471,8 +1510,13 @@ export function CredentialsManager() { )} {createError && ( -
- {createError} +
+
+ +

+ {createError} +

+
)}
@@ -1488,10 +1532,13 @@ export function CredentialsManager() { (createType === 'oauth' ? !createOAuthProviderId || !createDisplayName.trim() || - connectOAuthService.isPending + connectOAuthService.isPending || + Boolean(existingOAuthDisplayName) : createSecretInputMode === 'bulk' ? !createBulkText.trim() - : !createEnvKey.trim() || !createEnvValue.trim()) || + : !createEnvKey.trim() || + !createEnvValue.trim() || + Boolean(selectedExistingEnvCredential)) || createCredential.isPending || savePersonalEnvironment.isPending || upsertWorkspaceEnvironment.isPending || @@ -1508,9 +1555,7 @@ export function CredentialsManager() { upsertWorkspaceEnvironment.isPending ? 'Importing...' : 'Import all' - : selectedExistingEnvCredential - ? 'Update and use existing' - : 'Create'} + : 'Create'} diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index 26f8ad7e6..840e7825b 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -1,11 +1,14 @@ import { db } from '@sim/db' -import { environment } from '@sim/db/schema' +import { credential, environment, workflow, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { z } from 'zod' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' -import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment' +import { + syncPersonalEnvCredentialsForUser, + syncWorkspaceEnvCredentials, +} from '@/lib/credentials/environment' interface SetEnvironmentVariablesParams { variables: Record | Array<{ name: string; value: string }> @@ -55,79 +58,179 @@ export const setEnvironmentVariablesServerTool: BaseServerTool) || {} + const requestedKeys = Object.keys(validatedVariables) + const workflowId = params.workflowId - const toEncrypt: Record = {} - const added: string[] = [] - const updated: string[] = [] - for (const [key, newVal] of Object.entries(validatedVariables)) { - if (!(key in existingEncrypted)) { - toEncrypt[key] = newVal - added.push(key) - } else { - try { - const { decrypted } = await decryptSecret(existingEncrypted[key]) - if (decrypted !== newVal) { - toEncrypt[key] = newVal - updated.push(key) - } - } catch { - toEncrypt[key] = newVal - updated.push(key) + const workspaceKeySet = new Set() + let resolvedWorkspaceId: string | null = null + + if (requestedKeys.length > 0 && workflowId) { + const [wf] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (wf?.workspaceId) { + resolvedWorkspaceId = wf.workspaceId + const existingWorkspaceCredentials = await db + .select({ envKey: credential.envKey }) + .from(credential) + .where( + and( + eq(credential.workspaceId, wf.workspaceId), + eq(credential.type, 'env_workspace'), + inArray(credential.envKey, requestedKeys) + ) + ) + + for (const row of existingWorkspaceCredentials) { + if (row.envKey) workspaceKeySet.add(row.envKey) } } } - const newlyEncrypted = await Object.entries(toEncrypt).reduce( - async (accP, [key, val]) => { - const acc = await accP - const { encrypted } = await encryptSecret(val) - return { ...acc, [key]: encrypted } - }, - Promise.resolve({} as Record) - ) + const personalVars: Record = {} + const workspaceVars: Record = {} - const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted } + for (const [key, value] of Object.entries(validatedVariables)) { + if (workspaceKeySet.has(key)) { + workspaceVars[key] = value + } else { + personalVars[key] = value + } + } - // Save to personal environment variables (keyed by userId) - await db - .insert(environment) - .values({ - id: crypto.randomUUID(), + const added: string[] = [] + const updated: string[] = [] + const workspaceUpdated: string[] = [] + + if (Object.keys(personalVars).length > 0) { + const existingData = await db + .select() + .from(environment) + .where(eq(environment.userId, authenticatedUserId)) + .limit(1) + const existingEncrypted = (existingData[0]?.variables as Record) || {} + + const toEncrypt: Record = {} + for (const [key, newVal] of Object.entries(personalVars)) { + if (!(key in existingEncrypted)) { + toEncrypt[key] = newVal + added.push(key) + } else { + try { + const { decrypted } = await decryptSecret(existingEncrypted[key]) + if (decrypted !== newVal) { + toEncrypt[key] = newVal + updated.push(key) + } + } catch { + toEncrypt[key] = newVal + updated.push(key) + } + } + } + + const newlyEncrypted = await Object.entries(toEncrypt).reduce( + async (accP, [key, val]) => { + const acc = await accP + const { encrypted } = await encryptSecret(val) + return { ...acc, [key]: encrypted } + }, + Promise.resolve({} as Record) + ) + + const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted } + + await db + .insert(environment) + .values({ + id: crypto.randomUUID(), + userId: authenticatedUserId, + variables: finalEncrypted, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: finalEncrypted, updatedAt: new Date() }, + }) + + await syncPersonalEnvCredentialsForUser({ userId: authenticatedUserId, - variables: finalEncrypted, - updatedAt: new Date(), + envKeys: Object.keys(finalEncrypted), }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: finalEncrypted, updatedAt: new Date() }, + } + + if (Object.keys(workspaceVars).length > 0 && resolvedWorkspaceId) { + const wsRows = await db + .select() + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, resolvedWorkspaceId)) + .limit(1) + + const existingWsEncrypted = (wsRows[0]?.variables as Record) || {} + + const toEncryptWs: Record = {} + for (const [key, newVal] of Object.entries(workspaceVars)) { + toEncryptWs[key] = newVal + workspaceUpdated.push(key) + } + + const newlyEncryptedWs = await Object.entries(toEncryptWs).reduce( + async (accP, [key, val]) => { + const acc = await accP + const { encrypted } = await encryptSecret(val) + return { ...acc, [key]: encrypted } + }, + Promise.resolve({} as Record) + ) + + const mergedWs = { ...existingWsEncrypted, ...newlyEncryptedWs } + + await db + .insert(workspaceEnvironment) + .values({ + id: crypto.randomUUID(), + workspaceId: resolvedWorkspaceId, + variables: mergedWs, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: mergedWs, updatedAt: new Date() }, + }) + + await syncWorkspaceEnvCredentials({ + workspaceId: resolvedWorkspaceId, + envKeys: Object.keys(workspaceVars), + actingUserId: authenticatedUserId, }) + } - await syncPersonalEnvCredentialsForUser({ - userId: authenticatedUserId, - envKeys: Object.keys(finalEncrypted), - }) + const totalProcessed = added.length + updated.length + workspaceUpdated.length - logger.info('Saved personal environment variables', { + logger.info('Saved environment variables', { userId: authenticatedUserId, addedCount: added.length, updatedCount: updated.length, - totalCount: Object.keys(finalEncrypted).length, + workspaceUpdatedCount: workspaceUpdated.length, }) + const parts: string[] = [] + if (added.length > 0) parts.push(`${added.length} personal secret(s) added`) + if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`) + if (workspaceUpdated.length > 0) + parts.push(`${workspaceUpdated.length} workspace secret(s) updated`) + return { - message: `Successfully processed ${Object.keys(validatedVariables).length} personal environment variable(s): ${added.length} added, ${updated.length} updated`, + message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`, variableCount: Object.keys(validatedVariables).length, variableNames: Object.keys(validatedVariables), - totalVariableCount: Object.keys(finalEncrypted).length, addedVariables: added, updatedVariables: updated, + workspaceUpdatedVariables: workspaceUpdated, } }, }