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 3ea43b9c6..f5ab9a349 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 @@ -66,12 +66,75 @@ const roleOptions = [ type CreateCredentialType = 'oauth' | 'secret' type SecretScope = 'workspace' | 'personal' +type SecretInputMode = 'single' | 'bulk' const createTypeOptions = [ { value: 'oauth', label: 'OAuth Account' }, { value: 'secret', label: 'Secret' }, ] as const +interface ParsedEnvEntry { + key: string + value: string +} + +/** + * Parses `.env`-style text into key-value pairs. + * Supports `KEY=VALUE`, quoted values, comments (#), and blank lines. + */ +function parseEnvText(text: string): { entries: ParsedEnvEntry[]; errors: string[] } { + const entries: ParsedEnvEntry[] = [] + const errors: string[] = [] + const seenKeys = new Set() + + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const raw = lines[i].trim() + if (!raw || raw.startsWith('#')) continue + + const eqIndex = raw.indexOf('=') + if (eqIndex === -1) { + errors.push(`Line ${i + 1}: missing "=" separator`) + continue + } + + const key = raw.slice(0, eqIndex).trim() + let value = raw.slice(eqIndex + 1).trim() + + if (!key) { + errors.push(`Line ${i + 1}: empty key`) + continue + } + + if (!isValidEnvVarName(key)) { + errors.push(`Line ${i + 1}: "${key}" must contain only letters, numbers, and underscores`) + continue + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (!value) { + errors.push(`Line ${i + 1}: "${key}" has an empty value`) + continue + } + + if (seenKeys.has(key.toUpperCase())) { + errors.push(`Line ${i + 1}: duplicate key "${key}"`) + continue + } + + seenKeys.add(key.toUpperCase()) + entries.push({ key, value }) + } + + return { entries, errors } +} + function getSecretCredentialType( scope: SecretScope ): Extract { @@ -112,6 +175,8 @@ export function CredentialsManager() { const [createEnvKey, setCreateEnvKey] = useState('') const [createEnvValue, setCreateEnvValue] = useState('') const [createOAuthProviderId, setCreateOAuthProviderId] = useState('') + const [createSecretInputMode, setCreateSecretInputMode] = useState('single') + const [createBulkText, setCreateBulkText] = useState('') const [createError, setCreateError] = useState(null) const [detailsError, setDetailsError] = useState(null) const [selectedEnvValueDraft, setSelectedEnvValueDraft] = useState('') @@ -369,10 +434,12 @@ export function CredentialsManager() { const resetCreateForm = () => { setCreateType('oauth') setCreateSecretScope('personal') + setCreateSecretInputMode('single') setCreateDisplayName('') setCreateDescription('') setCreateEnvKey('') setCreateEnvValue('') + setCreateBulkText('') setCreateOAuthProviderId('') setCreateError(null) setShowCreateOAuthRequiredModal(false) @@ -461,6 +528,11 @@ export function CredentialsManager() { return } + if (createSecretInputMode === 'bulk') { + await handleBulkCreateSecrets() + return + } + if (!createEnvKey.trim()) return const normalizedEnvKey = normalizeEnvKeyInput(createEnvKey) if (!isValidEnvVarName(normalizedEnvKey)) { @@ -520,6 +592,74 @@ export function CredentialsManager() { } } + const handleBulkCreateSecrets = async () => { + if (!workspaceId) return + setCreateError(null) + + const { entries, errors } = parseEnvText(createBulkText) + if (errors.length > 0) { + setCreateError(errors.join('\n')) + return + } + + if (entries.length === 0) { + setCreateError('No valid KEY=VALUE pairs found. Add one per line, e.g. API_KEY=sk-abc123') + return + } + + try { + const newVars: Record = {} + for (const entry of entries) { + newVars[entry.key] = entry.value + } + + if (createSecretType === 'env_personal') { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + + await savePersonalEnvironment.mutateAsync({ + variables: { ...personalVariables, ...newVars }, + }) + } else { + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { ...workspaceVariables, ...newVars }, + }) + } + + let lastCredentialId: string | null = null + for (const entry of entries) { + const response = await createCredential.mutateAsync({ + workspaceId, + type: createSecretType, + envKey: entry.key, + }) + if (response?.credential?.id) { + lastCredentialId = response.credential.id + } + } + + if (lastCredentialId) { + setSelectedCredentialId(lastCredentialId) + } + + await refetchCredentials() + + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to create secrets' + setCreateError(message) + logger.error('Failed to bulk create secrets', error) + } + } + const handleConnectOAuthService = async () => { if (!selectedOAuthService) { setCreateError('Select an OAuth service before connecting.') @@ -691,10 +831,13 @@ export function CredentialsManager() { onClick={() => handleSelectCredential(credential)} >
-

+

{credential.displayName}

- + {typeLabel(credential.type)}
@@ -1071,83 +1214,143 @@ export function CredentialsManager() {
- - { - setCreateEnvKey(event.target.value) - }} - placeholder='API_KEY' - autoComplete='off' - autoCapitalize='none' - autoCorrect='off' - spellCheck={false} - data-lpignore='true' - data-1p-ignore='true' - className='mt-[6px]' - /> -

- Use it in blocks as {'{{KEY}}'}, for example {'{{API_KEY}}'}. -

-
-
- - setCreateEnvValue(event.target.value)} - placeholder='Enter secret value' - autoComplete='new-password' - autoCapitalize='none' - autoCorrect='off' - spellCheck={false} - data-lpignore='true' - data-1p-ignore='true' - className='mt-[6px]' - /> -
-
- -