diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index f0429234488..3ed5d2b9164 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -10,8 +10,8 @@ import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { + deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, - syncWorkspaceEnvCredentials, } from '@/lib/credentials/environment' import { captureServerEvent } from '@/lib/posthog/server' @@ -317,10 +317,9 @@ export async function DELETE( set: { variables: current, updatedAt: new Date() }, }) - await syncWorkspaceEnvCredentials({ + await deleteWorkspaceEnvCredentials({ workspaceId: access.credential.workspaceId, - envKeys: Object.keys(current), - actingUserId: session.user.id, + removedKeys: [access.credential.envKey], }) captureServerEvent( diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 14d17a4cb27..660062a2fd6 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -9,7 +9,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' -import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' +import { + createWorkspaceEnvCredentials, + deleteWorkspaceEnvCredentials, +} from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -126,11 +129,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ set: { variables: merged, updatedAt: new Date() }, }) - await syncWorkspaceEnvCredentials({ - workspaceId, - envKeys: Object.keys(merged), - actingUserId: userId, - }) + const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted)) + await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId }) recordAudit({ workspaceId, @@ -215,11 +215,7 @@ export async function DELETE( set: { variables: current, updatedAt: new Date() }, }) - await syncWorkspaceEnvCredentials({ - workspaceId, - envKeys: Object.keys(current), - actingUserId: userId, - }) + await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys }) recordAudit({ workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index cf2f07519d1..970a5b3d7bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -25,6 +25,7 @@ import { } from '@/components/emcn' import { Input } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' +import { cn } from '@/lib/core/utils/cn' import { clearPendingCredentialCreateRequest, PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, @@ -59,7 +60,7 @@ const logger = createLogger('SecretsManager') const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto_auto] items-center' const COL_SPAN_ALL = 'col-span-5' -const CONFLICT_CLASS = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]' +const CONFLICT_CLASS = 'border-[var(--text-error)] bg-[var(--error-muted)]' const ROLE_OPTIONS = [ { value: 'member', label: 'Member' }, @@ -121,6 +122,60 @@ function validateEnvVarKey(key: string): string | undefined { return undefined } +/** + * Parses a single `.env`-style line into a key/value pair. + * Handles `export KEY=VALUE`, quoted values, inline comments, and base64 false positives. + * Returns null for blank lines, comments, and invalid entries. + */ +function parseEnvVarLine(line: string): UIEnvironmentVariable | null { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return null + + const withoutExport = trimmed.replace(/^export\s+/, '') + const equalIndex = withoutExport.indexOf('=') + if (equalIndex === -1 || equalIndex === 0) return null + + const potentialKey = withoutExport.substring(0, equalIndex).trim() + if (!isValidEnvVarName(potentialKey)) return null + + let value = withoutExport.substring(equalIndex + 1) + + const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_') + const valueIsJustPadding = /^=+$/.test(value.trim()) + if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) return null + + const trimmedValue = value.trim() + if ( + !trimmedValue.startsWith('"') && + !trimmedValue.startsWith("'") && + !trimmedValue.startsWith('`') + ) { + const commentIndex = value.search(/\s#/) + if (commentIndex !== -1) value = value.substring(0, commentIndex) + } + + value = value.trim() + + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('`') && value.endsWith('`'))) + ) { + value = value.slice(1, -1) + } + + return { key: potentialKey, value, id: generateRowId() } +} + +/** Parses an array of raw text lines, returning only valid non-empty KEY=VALUE entries. */ +function parseValidEnvVars(lines: string[]): UIEnvironmentVariable[] { + return lines + .map(parseEnvVarLine) + .filter((parsed): parsed is UIEnvironmentVariable => parsed !== null) + .filter(({ key, value }) => key && value) +} + interface WorkspaceVariableRowProps { envKey: string value: string @@ -197,7 +252,7 @@ function WorkspaceVariableRow({ variant='default' onClick={() => onViewDetails(envKey)} disabled={!hasCredential} - className={`ml-2 h-9 ${!hasCredential ? 'opacity-40' : ''}`} + className={cn('ml-2 h-9', !hasCredential && 'opacity-40')} > Details @@ -221,17 +276,26 @@ interface NewWorkspaceVariableRowProps { envVar: UIEnvironmentVariable index: number onUpdate: (index: number, field: 'key' | 'value', value: string) => void + onPaste?: (e: React.ClipboardEvent, index: number) => void } -function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariableRowProps) { +function NewWorkspaceVariableRow({ + envVar, + index, + onUpdate, + onPaste, +}: NewWorkspaceVariableRowProps) { + const [valueFocused, setValueFocused] = useState(false) const keyError = validateEnvVarKey(envVar.key) const hasContent = Boolean(envVar.key || envVar.value) return (
onUpdate(index, 'key', e.target.value)} + onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='API_KEY' name={`new_workspace_key_${envVar.id || index}_${Math.random()}`} autoComplete='off' @@ -239,20 +303,26 @@ function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariab spellCheck='false' readOnly onFocus={(e) => e.target.removeAttribute('readOnly')} - className={`h-9 ${keyError ? 'border-[var(--text-error)]' : ''}`} + className={cn('h-9', keyError && 'border-[var(--text-error)]')} />
onUpdate(index, 'value', e.target.value)} + onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='Enter value' - type='text' + type={valueFocused ? 'text' : 'password'} name={`new_workspace_value_${envVar.id || index}_${Math.random()}`} autoComplete='off' autoCapitalize='off' spellCheck='false' readOnly - onFocus={(e) => e.target.removeAttribute('readOnly')} + onFocus={(e) => { + setValueFocused(true) + e.target.removeAttribute('readOnly') + }} + onBlur={() => setValueFocused(false)} className='col-span-2 ml-0 h-9' /> @@ -264,7 +334,7 @@ function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariab onUpdate(index, 'value', '') }} disabled={!hasContent} - className={`h-9 w-9 ${!hasContent ? 'opacity-30' : ''}`} + className={cn('h-9 w-9', !hasContent && 'opacity-30')} > @@ -273,7 +343,10 @@ function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariab {keyError && (
{keyError}
@@ -336,7 +409,6 @@ export function CredentialsManager() { const isLoading = isPersonalLoading || isWorkspaceLoading const variables = useMemo(() => personalEnvData || {}, [personalEnvData]) - // --- List view state --- const [envVars, setEnvVars] = useState([]) const [newWorkspaceRows, setNewWorkspaceRows] = useState([ createEmptyEnvVar(), @@ -349,7 +421,6 @@ export function CredentialsManager() { const [pendingKeyValue, setPendingKeyValue] = useState('') const [changeToken, setChangeToken] = useState(0) - // --- Detail view state --- const [selectedCredentialId, setSelectedCredentialId] = useState(null) const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState< string | null | undefined @@ -370,7 +441,6 @@ export function CredentialsManager() { const shouldBlockNavRef = useRef(false) const pendingNavigationUrlRef = useRef(null) - // --- Credential lookups --- const envKeyToCredential = useMemo(() => { const map = new Map() for (const cred of envCredentials) { @@ -398,7 +468,6 @@ export function CredentialsManager() { } } - // --- Detail view hooks --- const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers( selectedCredential?.id ) @@ -407,7 +476,6 @@ export function CredentialsManager() { const upsertMember = useUpsertWorkspaceCredentialMember() const removeMember = useRemoveWorkspaceCredentialMember() - // --- Detail view computed --- const activeMembers = useMemo( () => members.filter((member) => member.status === 'active'), [members] @@ -438,7 +506,6 @@ export function CredentialsManager() { : false const isDetailsDirty = isDescriptionDirty || isDisplayNameDirty - // --- List view computed --- const filteredEnvVars = useMemo(() => { const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index })) if (!searchTerm.trim()) return mapped @@ -526,7 +593,6 @@ export function CredentialsManager() { useEffect(() => () => resetNavGuard(), [resetNavGuard]) - // --- Effects --- useEffect(() => { if (hasSavedRef.current) return @@ -611,7 +677,6 @@ export function CredentialsManager() { } }, []) - // --- Pending credential create request --- const applyPendingCredentialCreateRequest = useCallback( (request: PendingCredentialCreateRequest) => { if (request.workspaceId !== workspaceId) return @@ -666,56 +731,52 @@ export function CredentialsManager() { } }, [workspaceId, applyPendingCredentialCreateRequest]) - // --- Detail view handlers --- - const handleSelectCredential = useCallback((credentialId: string) => { + const handleSelectCredential = (credentialId: string) => { setSelectedCredentialId(credentialId) setDetailsError(null) setMemberUserId('') setMemberRole('member') - }, []) + } - const handleViewDetails = useCallback( - async (envKey: string, type: 'env_workspace' | 'env_personal') => { - const existing = envKeyToCredential.get(envKey) - if (existing) { - handleSelectCredential(existing.id) - return - } + const handleViewDetails = async (envKey: string, type: 'env_workspace' | 'env_personal') => { + const existing = envKeyToCredential.get(envKey) + if (existing) { + handleSelectCredential(existing.id) + return + } - try { - const result = await createCredential.mutateAsync({ - workspaceId, - type, - displayName: envKey, - envKey, - ...(type === 'env_personal' ? { envOwnerUserId: session?.user?.id } : {}), - }) - if (result.credential?.id) { - handleSelectCredential(result.credential.id) - } - } catch (error) { - logger.error('Failed to create credential record', error) + try { + const result = await createCredential.mutateAsync({ + workspaceId, + type, + displayName: envKey, + envKey, + ...(type === 'env_personal' ? { envOwnerUserId: session?.user?.id } : {}), + }) + if (result.credential?.id) { + handleSelectCredential(result.credential.id) } - }, - [envKeyToCredential, handleSelectCredential, createCredential, workspaceId, session?.user?.id] - ) + } catch (error) { + logger.error('Failed to create credential record', error) + } + } - const handleBackAttempt = useCallback(() => { + const handleBackAttempt = () => { if (isDetailsDirty && !updateCredential.isPending) { setShowDetailUnsavedChanges(true) } else { setSelectedCredentialId(null) } - }, [isDetailsDirty, updateCredential.isPending]) + } - const handleDiscardDetailChanges = useCallback(() => { + const handleDiscardDetailChanges = () => { setShowDetailUnsavedChanges(false) setSelectedDescriptionDraft(selectedCredential?.description || '') setSelectedDisplayNameDraft(selectedCredential?.displayName || '') setSelectedCredentialId(null) - }, [selectedCredential]) + } - const handleSaveDetails = useCallback(async () => { + const handleSaveDetails = async () => { if (!selectedCredential || !isSelectedAdmin || !isDetailsDirty || updateCredential.isPending) return setDetailsError(null) @@ -733,18 +794,9 @@ export function CredentialsManager() { setDetailsError(message) logger.error('Failed to save secret details', error) } - }, [ - selectedCredential, - isSelectedAdmin, - isDetailsDirty, - isDisplayNameDirty, - isDescriptionDirty, - selectedDisplayNameDraft, - selectedDescriptionDraft, - updateCredential, - ]) + } - const handleAddMember = useCallback(async () => { + const handleAddMember = async () => { if (!memberUserId || !selectedCredential) return try { await upsertMember.mutateAsync({ @@ -757,74 +809,61 @@ export function CredentialsManager() { } catch (error) { logger.error('Failed to add member', error) } - }, [selectedCredential, memberUserId, memberRole]) - - const handleRemoveMember = useCallback( - async (userId: string) => { - if (!selectedCredential) return - try { - await removeMember.mutateAsync({ credentialId: selectedCredential.id, userId }) - } catch (error) { - logger.error('Failed to remove member', error) - } - }, - [selectedCredential] - ) + } - const handleChangeMemberRole = useCallback( - async (userId: string, role: WorkspaceCredentialRole) => { - if (!selectedCredential) return - try { - await upsertMember.mutateAsync({ credentialId: selectedCredential.id, userId, role }) - } catch (error) { - logger.error('Failed to change member role', error) - } - }, - [selectedCredential] - ) + const handleRemoveMember = async (userId: string) => { + if (!selectedCredential) return + try { + await removeMember.mutateAsync({ credentialId: selectedCredential.id, userId }) + } catch (error) { + logger.error('Failed to remove member', error) + } + } - // --- List view handlers --- - const handleWorkspaceKeyRename = useCallback( - (currentKey: string, currentValue: string) => { - const newKey = pendingKeyValue.trim() - if (!renamingKey || renamingKey !== currentKey) return - setRenamingKey(null) - if (!newKey || newKey === currentKey) return - - setWorkspaceVars((prev) => { - const next = { ...prev } - delete next[currentKey] - next[newKey] = currentValue - return next - }) - }, - [pendingKeyValue, renamingKey] - ) + const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { + if (!selectedCredential) return + try { + await upsertMember.mutateAsync({ credentialId: selectedCredential.id, userId, role }) + } catch (error) { + logger.error('Failed to change member role', error) + } + } + + const handleWorkspaceKeyRename = (currentKey: string, currentValue: string) => { + const newKey = pendingKeyValue.trim() + if (!renamingKey || renamingKey !== currentKey) return + setRenamingKey(null) + if (!newKey || newKey === currentKey) return - const handleWorkspaceValueChange = useCallback((key: string, value: string) => { + setWorkspaceVars((prev) => { + const next = { ...prev } + delete next[currentKey] + next[newKey] = currentValue + return next + }) + } + + const handleWorkspaceValueChange = (key: string, value: string) => { setWorkspaceVars((prev) => ({ ...prev, [key]: value })) - }, []) + } - const handleDeleteWorkspaceVar = useCallback((key: string) => { + const handleDeleteWorkspaceVar = (key: string) => { setWorkspaceVars((prev) => { const next = { ...prev } delete next[key] return next }) - }, []) + } - const updateNewWorkspaceRow = useCallback( - (index: number, field: 'key' | 'value', value: string) => { - setNewWorkspaceRows((prev) => updateEnvVarArray(prev, index, field, value)) - }, - [] - ) + const updateNewWorkspaceRow = (index: number, field: 'key' | 'value', value: string) => { + setNewWorkspaceRows((prev) => updateEnvVarArray(prev, index, field, value)) + } - const updateEnvVar = useCallback((index: number, field: 'key' | 'value', value: string) => { + const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => { setEnvVars((prev) => updateEnvVarArray(prev, index, field, value)) - }, []) + } - const removeEnvVar = useCallback((index: number) => { + const removeEnvVar = (index: number) => { setEnvVars((prev) => { const filtered = prev.filter((_, i) => i !== index) const hasTrailingEmpty = @@ -833,130 +872,105 @@ export function CredentialsManager() { !filtered[filtered.length - 1].value return hasTrailingEmpty ? filtered : [...filtered, createEmptyEnvVar()] }) - }, []) + } - const handleValueFocus = useCallback((index: number, e: React.FocusEvent) => { + const handleValueFocus = (index: number, e: React.FocusEvent) => { setFocusedValueIndex(index) e.target.scrollLeft = 0 - }, []) + } - const handleValueClick = useCallback((e: React.MouseEvent) => { + const handleValueClick = (e: React.MouseEvent) => { e.preventDefault() e.currentTarget.scrollLeft = 0 - }, []) - - const parseEnvVarLine = useCallback((line: string): UIEnvironmentVariable | null => { - const trimmed = line.trim() - - if (!trimmed || trimmed.startsWith('#')) return null - - const withoutExport = trimmed.replace(/^export\s+/, '') - - const equalIndex = withoutExport.indexOf('=') - if (equalIndex === -1 || equalIndex === 0) return null - - const potentialKey = withoutExport.substring(0, equalIndex).trim() - if (!isValidEnvVarName(potentialKey)) return null - - let value = withoutExport.substring(equalIndex + 1) + } - const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_') - const valueIsJustPadding = /^=+$/.test(value.trim()) - if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) { - return null - } + const handleSingleValuePaste = (text: string, index: number, inputType: 'key' | 'value') => { + setEnvVars((prev) => { + const newEnvVars = [...prev] + newEnvVars[index] = { ...newEnvVars[index], [inputType]: text } + return newEnvVars + }) + } - const trimmedValue = value.trim() - if ( - !trimmedValue.startsWith('"') && - !trimmedValue.startsWith("'") && - !trimmedValue.startsWith('`') - ) { - const commentIndex = value.search(/\s#/) - if (commentIndex !== -1) { - value = value.substring(0, commentIndex) + /** + * Paste handler for personal env var rows. + * Only prevents default when it actually handles the paste: KV patterns destructure into new rows, + * plain values overwrite the field. Falls through to native paste if pattern is detected but all + * values are empty (e.g. KEY=), avoiding silently swallowed input. + */ + const handlePaste = (e: React.ClipboardEvent, index: number) => { + const text = e.clipboardData.getData('text').trim() + if (!text) return + + const lines = text.split(/\r?\n/).filter((line) => line.trim()) + if (lines.length === 0) return + + const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as + | 'key' + | 'value' + + if (inputType) { + const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null) + if (!hasValidEnvVarPattern) { + e.preventDefault() + handleSingleValuePaste(text, index, inputType) + return } } - value = value.trim() - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) || - (value.startsWith('`') && value.endsWith('`')) - ) { - value = value.slice(1, -1) - } - - return { key: potentialKey, value, id: generateRowId() } - }, []) - - const handleSingleValuePaste = useCallback( - (text: string, index: number, inputType: 'key' | 'value') => { + const parsedVars = parseValidEnvVars(lines) + if (parsedVars.length > 0) { + e.preventDefault() setEnvVars((prev) => { - const newEnvVars = [...prev] - newEnvVars[index][inputType] = text - return newEnvVars + const existingVars = prev.filter((v) => v.key || v.value) + return [...existingVars, ...parsedVars, createEmptyEnvVar()] }) - }, - [] - ) + scrollToBottom() + } + } - const handleKeyValuePaste = useCallback( - (lines: string[]) => { - const parsedVars = lines - .map(parseEnvVarLine) - .filter((parsed): parsed is UIEnvironmentVariable => parsed !== null) - .filter(({ key, value }) => key && value) + /** + * Paste handler for workspace new-row inputs. + * Only prevents default when pasted text contains KEY=VALUE patterns; otherwise defers to + * native browser paste so cursor/selection semantics are preserved for plain values. + */ + const handleWorkspacePaste = (e: React.ClipboardEvent, _index: number) => { + const text = e.clipboardData.getData('text').trim() + if (!text) return - if (parsedVars.length > 0) { - setEnvVars((prev) => { - const existingVars = prev.filter((v) => v.key || v.value) - return [...existingVars, ...parsedVars, createEmptyEnvVar()] - }) - scrollToBottom() - } - }, - [parseEnvVarLine, scrollToBottom] - ) + const lines = text.split(/\r?\n/).filter((line) => line.trim()) + if (lines.length === 0) return - const handlePaste = useCallback( - (e: React.ClipboardEvent, index: number) => { - const text = e.clipboardData.getData('text').trim() - if (!text) return + const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as + | 'key' + | 'value' - const lines = text.split('\n').filter((line) => line.trim()) - if (lines.length === 0) return + if (inputType) { + const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null) + if (!hasValidEnvVarPattern) return + } + const parsedVars = parseValidEnvVars(lines) + if (parsedVars.length > 0) { e.preventDefault() + setNewWorkspaceRows((prev) => { + const existing = prev.filter((v) => v.key || v.value) + return [...existing, ...parsedVars, createEmptyEnvVar()] + }) + scrollToBottom() + } + } - const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as - | 'key' - | 'value' - - if (inputType) { - const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null) - if (!hasValidEnvVarPattern) { - handleSingleValuePaste(text, index, inputType) - return - } - } - - handleKeyValuePaste(lines) - }, - [parseEnvVarLine, handleSingleValuePaste, handleKeyValuePaste] - ) - - const resetToSaved = useCallback(() => { + const resetToSaved = () => { setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current))) setWorkspaceVars({ ...initialWorkspaceVarsRef.current }) setNewWorkspaceRows([createEmptyEnvVar()]) setShowUnsavedChanges(false) - }, []) + } const handleCancel = resetToSaved - const handleSave = useCallback(async () => { + const handleSave = async () => { if (isListSaving) return const prevInitialVars = [...initialVarsRef.current] @@ -1042,10 +1056,9 @@ export function CredentialsManager() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }) } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects and queryClient are stable (TanStack Query v5) - }, [isListSaving, envVars, workspaceVars, newWorkspaceRows, workspaceId]) + } - const handleDiscardAndNavigate = useCallback(() => { + const handleDiscardAndNavigate = () => { shouldBlockNavRef.current = false resetNavGuard() resetToSaved() @@ -1056,117 +1069,119 @@ export function CredentialsManager() { pendingNavigationUrlRef.current = null router.push(url) } - }, [router, resetToSaved, resetNavGuard]) - - const renderEnvVarRow = useCallback( - (envVar: UIEnvironmentVariable, originalIndex: number) => { - const isConflict = !!envVar.key && allWorkspaceKeys.has(envVar.key) - const keyError = validateEnvVarKey(envVar.key) - const maskedValueStyle = - focusedValueIndex !== originalIndex && !isConflict - ? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties) - : undefined - - const isComplete = Boolean(envVar.key && envVar.value) - const hasCredential = isComplete && envKeyToCredential.has(envVar.key) - - const hasContent = Boolean(envVar.key || envVar.value) - - return ( -
- updateEnvVar(originalIndex, 'key', e.target.value)} - onPaste={(e) => handlePaste(e, originalIndex)} - placeholder='API_KEY' - name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`} - autoComplete='off' - autoCapitalize='off' - spellCheck='false' - readOnly - onFocus={(e) => e.target.removeAttribute('readOnly')} - className={`h-9 ${isConflict ? CONFLICT_CLASS : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`} - /> -
- updateEnvVar(originalIndex, 'value', e.target.value)} - type='text' - onFocus={(e) => { - if (!isConflict) { - e.target.removeAttribute('readOnly') - handleValueFocus(originalIndex, e) - } - }} - onClick={handleValueClick} - onBlur={() => setFocusedValueIndex(null)} - onPaste={(e) => handlePaste(e, originalIndex)} - placeholder={isConflict ? 'Workspace override active' : 'Enter value'} - disabled={isConflict} - aria-disabled={isConflict} - name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`} - autoComplete='off' - autoCapitalize='off' - spellCheck='false' - readOnly={isConflict} - style={maskedValueStyle} - className={`h-9 ${isComplete ? '' : 'col-span-2'} ${isConflict ? `cursor-not-allowed ${CONFLICT_CLASS}` : ''}`} - /> - {isComplete && ( - + } + + const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => { + const isConflict = !!envVar.key && allWorkspaceKeys.has(envVar.key) + const keyError = validateEnvVarKey(envVar.key) + const maskedValueStyle = + focusedValueIndex !== originalIndex && !isConflict + ? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties) + : undefined + + const isComplete = Boolean(envVar.key && envVar.value) + const hasCredential = isComplete && envKeyToCredential.has(envVar.key) + + const hasContent = Boolean(envVar.key || envVar.value) + + return ( +
+ updateEnvVar(originalIndex, 'key', e.target.value)} + onPaste={(e) => handlePaste(e, originalIndex)} + placeholder='API_KEY' + name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} + className={cn( + 'h-9', + isConflict && CONFLICT_CLASS, + keyError && 'border-[var(--text-error)]' )} - - - - - {hasContent && Delete secret} - - {keyError && ( -
- {keyError} -
+ /> +
+ updateEnvVar(originalIndex, 'value', e.target.value)} + type='text' + onFocus={(e) => { + if (!isConflict) { + e.target.removeAttribute('readOnly') + handleValueFocus(originalIndex, e) + } + }} + onClick={handleValueClick} + onBlur={() => setFocusedValueIndex(null)} + onPaste={(e) => handlePaste(e, originalIndex)} + placeholder={isConflict ? 'Workspace override active' : 'Enter value'} + disabled={isConflict} + aria-disabled={isConflict} + name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly={isConflict} + style={maskedValueStyle} + className={cn( + 'h-9', + !isComplete && 'col-span-2', + isConflict && 'cursor-not-allowed', + isConflict && CONFLICT_CLASS )} - {isConflict && !keyError && ( -
+ {isComplete && ( + + )} + + +
- )} -
- ) - }, - [ - allWorkspaceKeys, - focusedValueIndex, - updateEnvVar, - handlePaste, - handleValueFocus, - handleValueClick, - removeEnvVar, - handleViewDetails, - envKeyToCredential, - ] - ) + + + + {hasContent && Delete secret} + + {keyError && ( +
+ {keyError} +
+ )} + {isConflict && !keyError && ( +
+ Workspace variable with the same name overrides this. Rename your personal key to use + it. +
+ )} +
+ ) + } const isPendingNavigation = pendingNavigationUrlRef.current !== null @@ -1208,9 +1223,10 @@ export function CredentialsManager() { Display Name - + {copyIdSuccess ? 'Copied!' : 'Copy credential ID'} @@ -1439,7 +1455,6 @@ export function CredentialsManager() { ) } - // List view return ( <>
@@ -1493,7 +1508,7 @@ export function CredentialsManager() { isLoading || !hasChanges || hasConflicts || hasInvalidKeys || isListSaving } variant='primary' - className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`} + className={cn((hasConflicts || hasInvalidKeys) && 'cursor-not-allowed opacity-50')} > {isListSaving ? 'Saving...' : 'Save'} @@ -1515,8 +1530,8 @@ export function CredentialsManager() {
-
- +
+ {Array.from({ length: 2 }, (_, i) => (
@@ -1529,13 +1544,16 @@ export function CredentialsManager() {
) : ( -
+
{(!searchTerm.trim() || filteredWorkspaceEntries.length > 0 || filteredNewWorkspaceRows.length > 0) && ( <>
Workspace
@@ -1569,16 +1587,20 @@ export function CredentialsManager() { envVar={row} index={originalIndex} onUpdate={updateNewWorkspaceRow} + onPaste={handleWorkspacePaste} /> ))} -
+
)} {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( <>
Personal
@@ -1597,7 +1619,10 @@ export function CredentialsManager() { Object.keys(workspaceVars).length > 0 || newWorkspaceRows.length > 0) && (
No secrets found matching “{searchTerm}”
diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index ad4bed88876..d9dbdce8c9c 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -100,11 +100,10 @@ async function upsertCredentialAdminMember(credentialId: string, adminUserId: st async function ensureWorkspaceCredentialMemberships( credentialId: string, - workspaceId: string, + memberUserIds: string[], ownerUserId: string ) { - const workspaceMemberUserIds = await getWorkspaceMemberUserIds(workspaceId) - if (!workspaceMemberUserIds.length) return + if (!memberUserIds.length) return const existingMemberships = await db .select({ @@ -117,14 +116,14 @@ async function ensureWorkspaceCredentialMemberships( .where( and( eq(credentialMember.credentialId, credentialId), - inArray(credentialMember.userId, workspaceMemberUserIds) + inArray(credentialMember.userId, memberUserIds) ) ) const byUserId = new Map(existingMemberships.map((row) => [row.userId, row])) const now = new Date() - for (const memberUserId of workspaceMemberUserIds) { + for (const memberUserId of memberUserIds) { const targetRole = memberUserId === ownerUserId ? 'admin' : 'member' const existing = byUserId.get(memberUserId) if (existing) { @@ -164,11 +163,14 @@ export async function syncWorkspaceEnvCredentials(params: { actingUserId: string }) { const { workspaceId, envKeys, actingUserId } = params - const [workspaceRow] = await db - .select({ ownerId: workspace.ownerId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const [[workspaceRow], memberUserIds] = await Promise.all([ + db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1), + getWorkspaceMemberUserIds(workspaceId), + ]) if (!workspaceRow) return @@ -217,7 +219,7 @@ export async function syncWorkspaceEnvCredentials(params: { } for (const credentialId of credentialIdsToEnsureMembership) { - await ensureWorkspaceCredentialMemberships(credentialId, workspaceId, workspaceRow.ownerId) + await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, workspaceRow.ownerId) } if (normalizedKeys.length > 0) { @@ -238,6 +240,97 @@ export async function syncWorkspaceEnvCredentials(params: { .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace'))) } +/** + * Creates credential records and bulk-inserts memberships for newly added workspace env keys. + * Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys are new. + */ +export async function createWorkspaceEnvCredentials(params: { + workspaceId: string + newKeys: string[] + actingUserId: string +}): Promise { + const { workspaceId, newKeys, actingUserId } = params + const keys = Array.from(new Set(newKeys.filter(Boolean))) + if (keys.length === 0) return + + const [[workspaceRow], memberUserIds] = await Promise.all([ + db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1), + getWorkspaceMemberUserIds(workspaceId), + ]) + + if (!workspaceRow) return + + const ownerUserId = workspaceRow.ownerId + const now = new Date() + const createdIds: string[] = [] + + for (const envKey of keys) { + const createdId = generateId() + try { + await db.insert(credential).values({ + id: createdId, + workspaceId, + type: 'env_workspace', + displayName: envKey, + envKey, + createdBy: actingUserId, + createdAt: now, + updatedAt: now, + }) + createdIds.push(createdId) + } catch (error: unknown) { + const code = getPostgresErrorCode(error) + if (code !== '23505') throw error + } + } + + if (createdIds.length === 0 || memberUserIds.length === 0) return + + // Bulk-insert memberships for all new credentials × all workspace members in one query + const membershipValues = createdIds.flatMap((credentialId) => + memberUserIds.map((memberUserId) => ({ + id: generateId(), + credentialId, + userId: memberUserId, + role: (memberUserId === ownerUserId ? 'admin' : 'member') as 'admin' | 'member', + status: 'active' as const, + joinedAt: now, + invitedBy: ownerUserId, + createdAt: now, + updatedAt: now, + })) + ) + + await db.insert(credentialMember).values(membershipValues).onConflictDoNothing() +} + +/** + * Deletes credential records (and their memberships via cascade) for removed workspace env keys. + * Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys were deleted. + */ +export async function deleteWorkspaceEnvCredentials(params: { + workspaceId: string + removedKeys: string[] +}): Promise { + const { workspaceId, removedKeys } = params + const keys = removedKeys.filter(Boolean) + if (keys.length === 0) return + + await db + .delete(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + inArray(credential.envKey, keys) + ) + ) +} + export async function syncPersonalEnvCredentialsForUser(params: { userId: string envKeys: string[] diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 226e2cb33b7..7442506b8f1 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -5,9 +5,9 @@ import { generateId } from '@sim/utils/id' import { eq, inArray } from 'drizzle-orm' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { + createWorkspaceEnvCredentials, getAccessibleEnvCredentials, syncPersonalEnvCredentialsForUser, - syncWorkspaceEnvCredentials, } from '@/lib/credentials/environment' const logger = createLogger('EnvironmentUtils') @@ -305,7 +305,8 @@ export async function upsertWorkspaceEnvVars( set: { variables: merged, updatedAt: new Date() }, }) - await syncWorkspaceEnvCredentials({ workspaceId, envKeys: Object.keys(merged), actingUserId }) + const newKeys = Object.keys(newVars).filter((k) => !(k in existingWsEncrypted)) + await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId }) return updatedKeys }