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 && (
-
@@ -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,
}
},
}