copilot + oauth name comflict

This commit is contained in:
Vikhyath Mondreti
2026-02-12 18:42:52 -08:00
parent 77bb048307
commit dcf40be189
2 changed files with 234 additions and 86 deletions

View File

@@ -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() {
/>
</div>
</div>
{existingOAuthDisplayName && (
<div className='rounded-[8px] border border-red-500/50 bg-red-50 p-[12px] dark:bg-red-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='text-[12px] text-red-700 dark:text-red-300'>
A credential named{' '}
<span className='font-medium'>
{existingOAuthDisplayName.displayName}
</span>{' '}
already exists.
</p>
</div>
</div>
)}
</div>
) : (
<div className='flex flex-col gap-[10px]'>
@@ -1411,28 +1449,29 @@ export function CredentialsManager() {
</div>
{selectedExistingEnvCredential && (
<div className='rounded-[8px] border border-[var(--brand-9)]/40 bg-[var(--surface-3)] px-[10px] py-[8px]'>
<p className='text-[12px] text-[var(--text-primary)]'>
This secret key already maps to credential{' '}
<span className='font-medium'>
{selectedExistingEnvCredential.displayName}
</span>
.
</p>
<p className='mt-[4px] text-[11px] text-[var(--text-tertiary)]'>
Create will update the secret value and reuse the existing credential.
</p>
<Button
variant='ghost'
className='mt-[6px]'
onClick={() => {
setSelectedCredentialId(selectedExistingEnvCredential.id)
setShowCreateModal(false)
resetCreateForm()
}}
>
Open existing credential
</Button>
<div className='rounded-[8px] border border-red-500/50 bg-red-50 p-[12px] dark:bg-red-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='text-[12px] text-red-700 dark:text-red-300'>
A secret with key{' '}
<span className='font-medium'>
{selectedExistingEnvCredential.displayName}
</span>{' '}
already exists.
</p>
</div>
</div>
)}
{!selectedExistingEnvCredential && crossScopeEnvConflict && (
<div className='rounded-[8px] border border-amber-500/50 bg-amber-50 p-[12px] dark:bg-amber-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400' />
<p className='text-[12px] text-amber-700 dark:text-amber-300'>
A workspace secret with key{' '}
<span className='font-medium'>{crossScopeEnvConflict.envKey}</span>{' '}
already exists. Workspace secrets take precedence at runtime.
</p>
</div>
</div>
)}
</>
@@ -1471,8 +1510,13 @@ export function CredentialsManager() {
)}
{createError && (
<div className='whitespace-pre-wrap rounded-[8px] border border-[var(--status-red)]/40 bg-[var(--status-red)]/10 px-[10px] py-[8px] text-[12px] text-[var(--status-red)]'>
{createError}
<div className='rounded-[8px] border border-red-500/50 bg-red-50 p-[12px] dark:bg-red-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='whitespace-pre-wrap text-[12px] text-red-700 dark:text-red-300'>
{createError}
</p>
</div>
</div>
)}
</div>
@@ -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'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -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<string, any> | Array<{ name: string; value: string }>
@@ -55,79 +58,179 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
const normalized = normalizeVariables(variables || {})
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
// Fetch existing personal environment variables
const existingData = await db
.select()
.from(environment)
.where(eq(environment.userId, authenticatedUserId))
.limit(1)
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
const requestedKeys = Object.keys(validatedVariables)
const workflowId = params.workflowId
const toEncrypt: Record<string, string> = {}
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<string>()
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<string, string>)
)
const personalVars: Record<string, string> = {}
const workspaceVars: Record<string, string> = {}
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<string, string>) || {}
const toEncrypt: Record<string, string> = {}
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<string, string>)
)
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<string, string>) || {}
const toEncryptWs: Record<string, string> = {}
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<string, string>)
)
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,
}
},
}