From 517f1a91b662181de25e4f803880edc69e35b576 Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Sat, 25 Oct 2025 09:08:49 -1000 Subject: [PATCH 1/6] fix(google-scopes): added forms and different drive scope (#1532) * added google forms scope and google drive scope * added back file scope --------- Co-authored-by: Adam Gough Co-authored-by: Adam Gough --- .../components/oauth-required-modal.tsx | 2 ++ apps/sim/blocks/blocks/google_docs.ts | 5 ++++- apps/sim/blocks/blocks/google_drive.ts | 20 +++++++++++++++---- apps/sim/lib/auth.ts | 3 +++ apps/sim/lib/oauth/oauth.ts | 15 +++++++++++--- apps/sim/tools/google_docs/create.ts | 5 ++++- apps/sim/tools/google_docs/read.ts | 5 ++++- apps/sim/tools/google_docs/write.ts | 5 ++++- apps/sim/tools/google_drive/create_folder.ts | 5 ++++- apps/sim/tools/google_drive/get_content.ts | 5 ++++- apps/sim/tools/google_drive/list.ts | 5 ++++- apps/sim/tools/google_drive/upload.ts | 5 ++++- 12 files changed, 65 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 19d1dd67fb..0877612530 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -38,11 +38,13 @@ const SCOPE_DESCRIPTIONS: Record = { 'https://www.googleapis.com/auth/gmail.modify': 'View and manage your email messages', // 'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages', // 'https://www.googleapis.com/auth/drive': 'View and manage your Google Drive files', + 'https://www.googleapis.com/auth/drive.readonly': 'View and read your Google Drive files', 'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files', // 'https://www.googleapis.com/auth/documents': 'View and manage your Google Docs', 'https://www.googleapis.com/auth/calendar': 'View and manage your calendar', 'https://www.googleapis.com/auth/userinfo.email': 'View your email address', 'https://www.googleapis.com/auth/userinfo.profile': 'View your basic profile info', + 'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to your Google Forms', 'read:page:confluence': 'Read Confluence pages', 'write:page:confluence': 'Write Confluence pages', 'read:me': 'Read your profile information', diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts index ca7ec792ab..3b83b3ab85 100644 --- a/apps/sim/blocks/blocks/google_docs.ts +++ b/apps/sim/blocks/blocks/google_docs.ts @@ -37,7 +37,10 @@ export const GoogleDocsBlock: BlockConfig = { required: true, provider: 'google-docs', serviceId: 'google-docs', - requiredScopes: ['https://www.googleapis.com/auth/drive.file'], + requiredScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], placeholder: 'Select Google account', }, // Document selector (basic mode) diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index f8b30c7b6b..60a6f9708f 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -37,7 +37,10 @@ export const GoogleDriveBlock: BlockConfig = { required: true, provider: 'google-drive', serviceId: 'google-drive', - requiredScopes: ['https://www.googleapis.com/auth/drive.file'], + requiredScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], placeholder: 'Select Google Drive account', }, // Create/Upload File Fields @@ -110,7 +113,10 @@ export const GoogleDriveBlock: BlockConfig = { canonicalParamId: 'folderId', provider: 'google-drive', serviceId: 'google-drive', - requiredScopes: ['https://www.googleapis.com/auth/drive.file'], + requiredScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select a parent folder', mode: 'basic', @@ -186,7 +192,10 @@ export const GoogleDriveBlock: BlockConfig = { canonicalParamId: 'folderId', provider: 'google-drive', serviceId: 'google-drive', - requiredScopes: ['https://www.googleapis.com/auth/drive.file'], + requiredScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select a parent folder', mode: 'basic', @@ -213,7 +222,10 @@ export const GoogleDriveBlock: BlockConfig = { canonicalParamId: 'folderId', provider: 'google-drive', serviceId: 'google-drive', - requiredScopes: ['https://www.googleapis.com/auth/drive.file'], + requiredScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select a folder to list files from', mode: 'basic', diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 1bc7bc06b9..94f085a99e 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -426,6 +426,7 @@ export const auth = betterAuth({ scopes: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/drive.file', ], prompt: 'consent', @@ -440,6 +441,7 @@ export const auth = betterAuth({ scopes: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/drive.file', ], prompt: 'consent', @@ -454,6 +456,7 @@ export const auth = betterAuth({ scopes: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/drive.file', ], prompt: 'consent', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 7dec2068e3..b7fa4240a4 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -124,7 +124,10 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-drive', icon: (props) => GoogleDriveIcon(props), baseProviderIcon: (props) => GoogleIcon(props), - scopes: ['https://www.googleapis.com/auth/drive.file'], + scopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], }, 'google-docs': { id: 'google-docs', @@ -133,7 +136,10 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-docs', icon: (props) => GoogleDocsIcon(props), baseProviderIcon: (props) => GoogleIcon(props), - scopes: ['https://www.googleapis.com/auth/drive.file'], + scopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], }, 'google-sheets': { id: 'google-sheets', @@ -142,7 +148,10 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-sheets', icon: (props) => GoogleSheetsIcon(props), baseProviderIcon: (props) => GoogleIcon(props), - scopes: ['https://www.googleapis.com/auth/drive.file'], + scopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], }, 'google-forms': { id: 'google-forms', diff --git a/apps/sim/tools/google_docs/create.ts b/apps/sim/tools/google_docs/create.ts index 4f025f4d79..3a21a715af 100644 --- a/apps/sim/tools/google_docs/create.ts +++ b/apps/sim/tools/google_docs/create.ts @@ -13,7 +13,10 @@ export const createTool: ToolConfig oauth: { required: true, provider: 'google-docs', - additionalScopes: ['https://www.googleapis.com/auth/drive.file'], + additionalScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.file', + ], }, params: { diff --git a/apps/sim/tools/google_docs/write.ts b/apps/sim/tools/google_docs/write.ts index 51b5bcb34a..a82b6b4648 100644 --- a/apps/sim/tools/google_docs/write.ts +++ b/apps/sim/tools/google_docs/write.ts @@ -9,7 +9,10 @@ export const writeTool: ToolConfig Date: Sat, 25 Oct 2025 14:58:25 -0700 Subject: [PATCH 2/6] feat(sso-chat-deployment): added sso auth option for chat deployment (#1729) * feat(sso-chat-deployment): added sso auth option for chat deployment * ack PR comments --- apps/sim/app/(auth)/sso/sso-form.tsx | 6 + apps/sim/app/api/chat/manage/[id]/route.ts | 4 +- apps/sim/app/api/chat/route.ts | 11 +- apps/sim/app/api/chat/utils.ts | 62 +++++- apps/sim/app/chat/[identifier]/chat.tsx | 19 +- .../app/chat/components/auth/sso/sso-auth.tsx | 209 ++++++++++++++++++ apps/sim/app/chat/components/index.ts | 1 + .../components/chat-deploy/chat-deploy.tsx | 2 +- .../chat-deploy/components/auth-selector.tsx | 24 +- .../chat-deploy/hooks/use-chat-deployment.ts | 3 +- .../chat-deploy/hooks/use-chat-form.ts | 6 +- packages/db/schema.ts | 4 +- 12 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 apps/sim/app/chat/components/auth/sso/sso-auth.tsx diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx index 29784afa9f..0a6dab76e6 100644 --- a/apps/sim/app/(auth)/sso/sso-form.tsx +++ b/apps/sim/app/(auth)/sso/sso-form.tsx @@ -71,6 +71,12 @@ export default function SSOForm() { } } + // Pre-fill email if provided in URL (e.g., from deployed chat SSO) + const emailParam = searchParams.get('email') + if (emailParam) { + setEmail(emailParam) + } + // Check for SSO error from redirect const error = searchParams.get('error') if (error) { diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 65d0583ac0..45fa151cc6 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -31,7 +31,7 @@ const chatUpdateSchema = z.object({ imageUrl: z.string().optional(), }) .optional(), - authType: z.enum(['public', 'password', 'email']).optional(), + authType: z.enum(['public', 'password', 'email', 'sso']).optional(), password: z.string().optional(), allowedEmails: z.array(z.string()).optional(), outputConfigs: z @@ -165,7 +165,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< updateData.allowedEmails = [] } else if (authType === 'password') { updateData.allowedEmails = [] - } else if (authType === 'email') { + } else if (authType === 'email' || authType === 'sso') { updateData.password = null } } diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index fb51159bb2..173c6c89a4 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -27,7 +27,7 @@ const chatSchema = z.object({ welcomeMessage: z.string(), imageUrl: z.string().optional(), }), - authType: z.enum(['public', 'password', 'email']).default('public'), + authType: z.enum(['public', 'password', 'email', 'sso']).default('public'), password: z.string().optional(), allowedEmails: z.array(z.string()).optional().default([]), outputConfigs: z @@ -98,6 +98,13 @@ export async function POST(request: NextRequest) { ) } + if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse( + 'At least one email or domain is required when using SSO access control', + 400 + ) + } + // Check if identifier is available const existingIdentifier = await db .select() @@ -163,7 +170,7 @@ export async function POST(request: NextRequest) { isActive: true, authType, password: encryptedPassword, - allowedEmails: authType === 'email' ? allowedEmails : [], + allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 060f522ba7..2b1f9dbe30 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -262,7 +262,67 @@ export async function validateChatAuth( } } - // Unknown auth type + if (authType === 'sso') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_sso' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'SSO authentication is required' } + } + + const { email, input, checkSSOAccess } = parsedBody + + if (checkSSOAccess) { + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + const allowedEmails = deployment.allowedEmails || [] + + if (allowedEmails.includes(email)) { + return { authorized: true } + } + + const domain = email.split('@')[1] + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + return { authorized: true } + } + + return { authorized: false, error: 'Email not authorized for SSO access' } + } + + const { auth } = await import('@/lib/auth') + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session || !session.user) { + return { authorized: false, error: 'auth_required_sso' } + } + + const userEmail = session.user.email + if (!userEmail) { + return { authorized: false, error: 'SSO session does not contain email' } + } + + const allowedEmails = deployment.allowedEmails || [] + + if (allowedEmails.includes(userEmail)) { + return { authorized: true } + } + + const domain = userEmail.split('@')[1] + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + return { authorized: true } + } + + return { authorized: false, error: 'Your email is not authorized to access this chat' } + } catch (error) { + logger.error(`[${requestId}] Error validating SSO:`, error) + return { authorized: false, error: 'SSO authentication error' } + } + } + return { authorized: false, error: 'Unsupported authentication type' } } diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index cb940f7568..47cb35fe32 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -14,6 +14,7 @@ import { ChatMessageContainer, EmailAuth, PasswordAuth, + SSOAuth, VoiceInterface, } from '@/app/chat/components' import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' @@ -32,7 +33,7 @@ interface ChatConfig { welcomeMessage?: string headerText?: string } - authType?: 'public' | 'password' | 'email' + authType?: 'public' | 'password' | 'email' | 'sso' outputConfigs?: Array<{ blockId: string; path?: string }> } @@ -119,7 +120,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [userHasScrolled, setUserHasScrolled] = useState(false) const isUserScrollingRef = useRef(false) - const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null) + const [authRequired, setAuthRequired] = useState<'password' | 'email' | 'sso' | null>(null) const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false) const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } = @@ -222,6 +223,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { setAuthRequired('email') return } + if (errorData.error === 'auth_required_sso') { + setAuthRequired('sso') + return + } } throw new Error(`Failed to load chat configuration: ${response.status}`) @@ -500,6 +505,16 @@ export default function ChatClient({ identifier }: { identifier: string }) { /> ) } + if (authRequired === 'sso') { + return ( + + ) + } } // Loading state while fetching config using the extracted component diff --git a/apps/sim/app/chat/components/auth/sso/sso-auth.tsx b/apps/sim/app/chat/components/auth/sso/sso-auth.tsx new file mode 100644 index 0000000000..6985a5f596 --- /dev/null +++ b/apps/sim/app/chat/components/auth/sso/sso-auth.tsx @@ -0,0 +1,209 @@ +'use client' + +import { type KeyboardEvent, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { quickValidateEmail } from '@/lib/email/validation' +import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' +import Nav from '@/app/(landing)/components/nav/nav' +import { inter } from '@/app/fonts/inter' +import { soehne } from '@/app/fonts/soehne/soehne' + +const logger = createLogger('SSOAuth') + +interface SSOAuthProps { + identifier: string + onAuthSuccess: () => void + title?: string + primaryColor?: string +} + +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors +} + +export default function SSOAuth({ + identifier, + onAuthSuccess, + title = 'chat', + primaryColor = 'var(--brand-primary-hover-hex)', +}: SSOAuthProps) { + const router = useRouter() + const [email, setEmail] = useState('') + const [emailErrors, setEmailErrors] = useState([]) + const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const checkCustomBrand = () => { + const computedStyle = getComputedStyle(document.documentElement) + const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() + + if (brandAccent && brandAccent !== '#6f3dfa') { + setButtonClass('auth-button-custom') + } else { + setButtonClass('auth-button-gradient') + } + } + + checkCustomBrand() + + window.addEventListener('resize', checkCustomBrand) + const observer = new MutationObserver(checkCustomBrand) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['style', 'class'], + }) + + return () => { + window.removeEventListener('resize', checkCustomBrand) + observer.disconnect() + } + }, []) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAuthenticate() + } + } + + const handleEmailChange = (e: React.ChangeEvent) => { + const newEmail = e.target.value + setEmail(newEmail) + setShowEmailValidationError(false) + setEmailErrors([]) + } + + const handleAuthenticate = async () => { + const emailValidationErrors = validateEmailField(email) + setEmailErrors(emailValidationErrors) + setShowEmailValidationError(emailValidationErrors.length > 0) + + if (emailValidationErrors.length > 0) { + return + } + + setIsLoading(true) + + try { + const checkResponse = await fetch(`/api/chat/${identifier}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email, checkSSOAccess: true }), + }) + + if (!checkResponse.ok) { + const errorData = await checkResponse.json() + setEmailErrors([errorData.error || 'Email not authorized for this chat']) + setShowEmailValidationError(true) + setIsLoading(false) + return + } + + const callbackUrl = `/chat/${identifier}` + const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}` + router.push(ssoUrl) + } catch (error) { + logger.error('SSO authentication error:', error) + setEmailErrors(['An error occurred during authentication']) + setShowEmailValidationError(true) + setIsLoading(false) + } + } + + return ( +
+
+ ) +} diff --git a/apps/sim/app/chat/components/index.ts b/apps/sim/app/chat/components/index.ts index eef5a82c46..4be7ea2f19 100644 --- a/apps/sim/app/chat/components/index.ts +++ b/apps/sim/app/chat/components/index.ts @@ -1,5 +1,6 @@ export { default as EmailAuth } from './auth/email/email-auth' export { default as PasswordAuth } from './auth/password/password-auth' +export { default as SSOAuth } from './auth/sso/sso-auth' export { ChatErrorState } from './error-state/error-state' export { ChatHeader } from './header/header' export { ChatInput } from './input/input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 0a70a2f149..43ad1bad39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -104,7 +104,7 @@ export function ChatDeploy({ (formData.authType !== 'password' || Boolean(formData.password.trim()) || Boolean(existingChat)) && - (formData.authType !== 'email' || formData.emails.length > 0) + ((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0) useEffect(() => { onValidationChange?.(isFormValid) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx index d377036659..497d39129f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Check, Copy, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react' import { Button, Card, CardContent, Input, Label } from '@/components/ui' +import { getEnv, isTruthy } from '@/lib/env' import { cn, generatePassword } from '@/lib/utils' import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form' @@ -63,13 +64,20 @@ export function AuthSelector({ onEmailsChange(emails.filter((e) => e !== email)) } + const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const authOptions = ssoEnabled + ? (['public', 'password', 'email', 'sso'] as const) + : (['public', 'password', 'email'] as const) + return (
{/* Auth Type Selection */} -
- {(['public', 'password', 'email'] as const).map((type) => ( +
+ {authOptions.map((type) => (

{type === 'public' && 'Anyone can access your chat'} {type === 'password' && 'Secure with a single password'} {type === 'email' && 'Restrict to specific emails'} + {type === 'sso' && 'Authenticate via SSO provider'}

@@ -207,10 +217,12 @@ export function AuthSelector({ )} - {authType === 'email' && ( + {(authType === 'email' || authType === 'sso') && ( -

Email Access Settings

+

+ {authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'} +

- Add specific emails or entire domains (@example.com) + {authType === 'email' + ? 'Add specific emails or entire domains (@example.com)' + : 'Add specific emails or entire domains (@example.com) that can access via SSO'}

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts index 2d03c0c903..7552e29337 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts @@ -85,7 +85,8 @@ export function useChatDeployment() { }, authType: formData.authType, password: formData.authType === 'password' ? formData.password : undefined, - allowedEmails: formData.authType === 'email' ? formData.emails : [], + allowedEmails: + formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, apiKey: deploymentInfo?.apiKey, deployApiEnabled: !existingChatId, // Only deploy API for new chats diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts index 753662453f..3c73581d74 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' -export type AuthType = 'public' | 'password' | 'email' +export type AuthType = 'public' | 'password' | 'email' | 'sso' export interface ChatFormData { identifier: string @@ -85,6 +85,10 @@ export function useChatForm(initialData?: Partial) { newErrors.emails = 'At least one email or domain is required when using email access control' } + if (formData.authType === 'sso' && formData.emails.length === 0) { + newErrors.emails = 'At least one email or domain is required when using SSO access control' + } + if (formData.selectedOutputBlocks.length === 0) { newErrors.outputBlocks = 'Please select at least one output block' } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index afcf756f8a..1c026d2540 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -648,9 +648,9 @@ export const chat = pgTable( customizations: json('customizations').default('{}'), // For UI customization options // Authentication options - authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email' + authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email', 'sso' password: text('password'), // Stored hashed, populated when authType is 'password' - allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' + allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' or 'sso' // Output configuration outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects From ad7b7912427a0bd0802ebeebf80dc0ab241091ac Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 25 Oct 2025 13:55:34 -1000 Subject: [PATCH 3/6] improvement(deployments): simplify deployments for chat and indicate active version (#1730) * improvement(deployment-ux): deployment should indicate and make details configurable when activating previous version * fix activation UI * remove redundant code * revert pulsing dot * fix redeploy bug * bill workspace owner for deployed chat * deployed chat * fix bugs * fix tests, address greptile * fix * ui bug to load api key * fix qdrant fetch tool --- apps/sim/app/api/chat/[identifier]/route.ts | 38 +- apps/sim/app/api/chat/manage/[id]/route.ts | 17 + apps/sim/app/api/chat/route.test.ts | 28 +- apps/sim/app/api/chat/route.ts | 16 +- .../app/api/workflows/[id]/deploy/route.ts | 110 +- .../deployments/[version]/activate/route.ts | 68 +- .../api-key-selector/api-key-selector.tsx | 469 +++++++++ .../components/chat-deploy/chat-deploy.tsx | 35 +- .../chat-deploy/hooks/use-chat-deployment.ts | 3 +- .../components/deploy-form/deploy-form.tsx | 504 +--------- .../components/deploy-modal/deploy-modal.tsx | 951 +++++++++--------- .../components/deployed-workflow-modal.tsx | 12 +- .../deployment-controls.tsx | 5 +- apps/sim/blocks/blocks/qdrant.ts | 42 +- apps/sim/lib/workflows/db-helpers.ts | 132 ++- apps/sim/lib/workflows/streaming.ts | 8 +- apps/sim/tools/qdrant/fetch_points.ts | 43 +- apps/sim/tools/qdrant/search_vector.ts | 47 +- apps/sim/tools/qdrant/types.ts | 2 + 19 files changed, 1428 insertions(+), 1102 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index d8cf0e570f..bab28e66ff 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { chat, workflow } from '@sim/db/schema' +import { chat, workflow, workspace } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' @@ -94,11 +94,12 @@ export async function POST( return addCorsHeaders(createErrorResponse('No input provided', 400), request) } - // Get the workflow for this chat + // Get the workflow and workspace owner for this chat const workflowResult = await db .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId, + variables: workflow.variables, }) .from(workflow) .where(eq(workflow.id, deployment.workflowId)) @@ -109,6 +110,22 @@ export async function POST( return addCorsHeaders(createErrorResponse('Chat workflow is not available', 503), request) } + let workspaceOwnerId = deployment.userId + if (workflowResult[0].workspaceId) { + const workspaceData = await db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workflowResult[0].workspaceId)) + .limit(1) + + if (workspaceData.length === 0) { + logger.error(`[${requestId}] Workspace not found for workflow ${deployment.workflowId}`) + return addCorsHeaders(createErrorResponse('Workspace not found', 500), request) + } + + workspaceOwnerId = workspaceData[0].ownerId + } + try { const selectedOutputs: string[] = [] if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { @@ -145,16 +162,19 @@ export async function POST( } } + const workflowForExecution = { + id: deployment.workflowId, + userId: deployment.userId, + workspaceId: workflowResult[0].workspaceId, + isDeployed: true, + variables: workflowResult[0].variables || {}, + } + const stream = await createStreamingResponse({ requestId, - workflow: { - id: deployment.workflowId, - userId: deployment.userId, - workspaceId: workflowResult[0].workspaceId, - isDeployed: true, - }, + workflow: workflowForExecution, input: workflowInput, - executingUserId: deployment.userId, + executingUserId: workspaceOwnerId, streamConfig: { selectedOutputs, isSecureMode: true, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 45fa151cc6..5048030782 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -8,6 +8,7 @@ import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { getEmailDomain } from '@/lib/urls/utils' import { encryptSecret } from '@/lib/utils' +import { deployWorkflow } from '@/lib/workflows/db-helpers' import { checkChatAccess } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -134,6 +135,22 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } + // Redeploy the workflow to ensure latest version is active + const deployResult = await deployWorkflow({ + workflowId: existingChat[0].workflowId, + deployedBy: session.user.id, + }) + + if (!deployResult.success) { + logger.warn( + `Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update` + ) + } else { + logger.info( + `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` + ) + } + let encryptedPassword if (password) { diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 7661f7b576..567d02c2f1 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -19,6 +19,7 @@ describe('Chat API Route', () => { const mockCreateErrorResponse = vi.fn() const mockEncryptSecret = vi.fn() const mockCheckWorkflowAccessForChatCreation = vi.fn() + const mockDeployWorkflow = vi.fn() beforeEach(() => { vi.resetModules() @@ -76,6 +77,14 @@ describe('Chat API Route', () => { vi.doMock('@/app/api/chat/utils', () => ({ checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, })) + + vi.doMock('@/lib/workflows/db-helpers', () => ({ + deployWorkflow: mockDeployWorkflow.mockResolvedValue({ + success: true, + version: 1, + deployedAt: new Date(), + }), + })) }) afterEach(() => { @@ -236,7 +245,7 @@ describe('Chat API Route', () => { it('should allow chat deployment when user owns workflow directly', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, + user: { id: 'user-id', email: 'user@example.com' }, }), })) @@ -283,7 +292,7 @@ describe('Chat API Route', () => { it('should allow chat deployment when user has workspace admin permission', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, + user: { id: 'user-id', email: 'user@example.com' }, }), })) @@ -393,10 +402,10 @@ describe('Chat API Route', () => { expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') }) - it('should reject if workflow is not deployed', async () => { + it('should auto-deploy workflow if not already deployed', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, + user: { id: 'user-id', email: 'user@example.com' }, }), })) @@ -415,6 +424,7 @@ describe('Chat API Route', () => { hasAccess: true, workflow: { userId: 'user-id', workspaceId: null, isDeployed: false }, }) + mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -423,11 +433,11 @@ describe('Chat API Route', () => { const { POST } = await import('@/app/api/chat/route') const response = await POST(req) - expect(response.status).toBe(400) - expect(mockCreateErrorResponse).toHaveBeenCalledWith( - 'Workflow must be deployed before creating a chat', - 400 - ) + expect(response.status).toBe(200) + expect(mockDeployWorkflow).toHaveBeenCalledWith({ + workflowId: 'workflow-123', + deployedBy: 'user-id', + }) }) }) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index 173c6c89a4..480d255945 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -9,6 +9,7 @@ import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' import { encryptSecret } from '@/lib/utils' +import { deployWorkflow } from '@/lib/workflows/db-helpers' import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -126,11 +127,20 @@ export async function POST(request: NextRequest) { return createErrorResponse('Workflow not found or access denied', 404) } - // Verify the workflow is deployed (required for chat deployment) - if (!workflowRecord.isDeployed) { - return createErrorResponse('Workflow must be deployed before creating a chat', 400) + // Always deploy/redeploy the workflow to ensure latest version + const result = await deployWorkflow({ + workflowId, + deployedBy: session.user.id, + }) + + if (!result.success) { + return createErrorResponse(result.error || 'Failed to deploy workflow', 500) } + logger.info( + `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})` + ) + // Encrypt password if provided let encryptedPassword = null if (authType === 'password' && password) { diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 3607b58ef8..e93f9f6dd3 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,10 +1,9 @@ import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db' -import { and, desc, eq, sql } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { deployWorkflow } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -138,37 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } catch (_err) {} - logger.debug(`[${requestId}] Getting current workflow state for deployment`) - - const normalizedData = await loadWorkflowFromNormalizedTables(id) - - if (!normalizedData) { - logger.error(`[${requestId}] Failed to load workflow from normalized tables`) - return createErrorResponse('Failed to load workflow state', 500) - } - - const currentState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - lastSaved: Date.now(), - } - - logger.debug(`[${requestId}] Current state retrieved from normalized tables:`, { - blocksCount: Object.keys(currentState.blocks).length, - edgesCount: currentState.edges.length, - loopsCount: Object.keys(currentState.loops).length, - parallelsCount: Object.keys(currentState.parallels).length, - }) - - if (!currentState || !currentState.blocks) { - logger.error(`[${requestId}] Invalid workflow state retrieved`, { currentState }) - throw new Error('Invalid workflow state: missing blocks') - } - - const deployedAt = new Date() - logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`) + logger.debug(`[${requestId}] Validating API key for deployment`) let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null let matchedKey: { @@ -260,45 +229,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Unable to determine deploying user', 400) } - await db.transaction(async (tx) => { - const [{ maxVersion }] = await tx - .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) - .from(workflowDeploymentVersion) - .where(eq(workflowDeploymentVersion.workflowId, id)) - - const nextVersion = Number(maxVersion) + 1 - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: false }) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - - await tx.insert(workflowDeploymentVersion).values({ - id: uuidv4(), - workflowId: id, - version: nextVersion, - state: currentState, - isActive: true, - createdAt: deployedAt, - createdBy: actorUserId, - }) + const deployResult = await deployWorkflow({ + workflowId: id, + deployedBy: actorUserId, + pinnedApiKeyId: matchedKey?.id, + includeDeployedState: true, + workflowName: workflowData!.name, + }) - const updateData: Record = { - isDeployed: true, - deployedAt, - deployedState: currentState, - } - if (providedApiKey && matchedKey) { - updateData.pinnedApiKeyId = matchedKey.id - } + if (!deployResult.success) { + return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500) + } - await tx.update(workflow).set(updateData).where(eq(workflow.id, id)) - }) + const deployedAt = deployResult.deployedAt! if (matchedKey) { try { @@ -313,31 +256,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - // Track workflow deployment - try { - const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') - - // Aggregate block types to understand which blocks are being used - const blockTypeCounts: Record = {} - for (const block of Object.values(currentState.blocks)) { - const blockType = (block as any).type || 'unknown' - blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 - } - - trackPlatformEvent('platform.workflow.deployed', { - 'workflow.id': id, - 'workflow.name': workflowData!.name, - 'workflow.blocks_count': Object.keys(currentState.blocks).length, - 'workflow.edges_count': currentState.edges.length, - 'workflow.has_loops': Object.keys(currentState.loops).length > 0, - 'workflow.has_parallels': Object.keys(currentState.parallels).length > 0, - 'workflow.api_key_type': keyInfo?.type || 'default', - 'workflow.block_types': JSON.stringify(blockTypeCounts), - }) - } catch (_e) { - // Silently fail - } - const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key' return createSuccessResponse({ diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts index 1a4209f51b..c9b636a877 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts @@ -1,4 +1,4 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' @@ -19,7 +19,11 @@ export async function POST( const { id, version } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'admin') + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } @@ -29,6 +33,52 @@ export async function POST( return createErrorResponse('Invalid version', 400) } + let providedApiKey: string | null = null + try { + const parsed = await request.json() + if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) { + providedApiKey = parsed.apiKey.trim() + } + } catch (_err) {} + + let pinnedApiKeyId: string | null = null + if (providedApiKey) { + const currentUserId = session?.user?.id + if (currentUserId) { + const [personalKey] = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.id, providedApiKey), + eq(apiKey.userId, currentUserId), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) + + if (personalKey) { + pinnedApiKeyId = personalKey.id + } else if (workflowData!.workspaceId) { + const [workspaceKey] = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.id, providedApiKey), + eq(apiKey.workspaceId, workflowData!.workspaceId), + eq(apiKey.type, 'workspace') + ) + ) + .limit(1) + + if (workspaceKey) { + pinnedApiKeyId = workspaceKey.id + } + } + } + } + const now = new Date() await db.transaction(async (tx) => { @@ -57,10 +107,16 @@ export async function POST( throw new Error('Deployment version not found') } - await tx - .update(workflow) - .set({ isDeployed: true, deployedAt: now }) - .where(eq(workflow.id, id)) + const updateData: Record = { + isDeployed: true, + deployedAt: now, + } + + if (pinnedApiKeyId) { + updateData.pinnedApiKeyId = pinnedApiKeyId + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, id)) }) return createSuccessResponse({ success: true, deployedAt: now }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx new file mode 100644 index 0000000000..fb18b657e9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx @@ -0,0 +1,469 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Check, Copy, Info, Loader2, Plus } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Input, + Label, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui' +import { createLogger } from '@/lib/logs/console/logger' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + +const logger = createLogger('ApiKeySelector') + +export interface ApiKey { + id: string + name: string + key: string + displayKey?: string + lastUsed?: string + createdAt: string + expiresAt?: string + createdBy?: string +} + +interface ApiKeysData { + workspace: ApiKey[] + personal: ApiKey[] +} + +interface ApiKeySelectorProps { + value: string + onChange: (keyId: string) => void + disabled?: boolean + apiKeys?: ApiKey[] + onApiKeyCreated?: () => void + showLabel?: boolean + label?: string + isDeployed?: boolean + deployedApiKeyDisplay?: string +} + +export function ApiKeySelector({ + value, + onChange, + disabled = false, + apiKeys = [], + onApiKeyCreated, + showLabel = true, + label = 'API Key', + isDeployed = false, + deployedApiKeyDisplay, +}: ApiKeySelectorProps) { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + const userPermissions = useUserPermissionsContext() + const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin + + const [apiKeysData, setApiKeysData] = useState(null) + const [isCreatingKey, setIsCreatingKey] = useState(false) + const [newKeyName, setNewKeyName] = useState('') + const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal') + const [newKey, setNewKey] = useState(null) + const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) + const [copySuccess, setCopySuccess] = useState(false) + const [isSubmittingCreate, setIsSubmittingCreate] = useState(false) + const [keysLoaded, setKeysLoaded] = useState(false) + const [createError, setCreateError] = useState(null) + const [justCreatedKeyId, setJustCreatedKeyId] = useState(null) + + useEffect(() => { + fetchApiKeys() + }, [workspaceId]) + + const fetchApiKeys = async () => { + try { + setKeysLoaded(false) + const [workspaceRes, personalRes] = await Promise.all([ + fetch(`/api/workspaces/${workspaceId}/api-keys`), + fetch('/api/users/me/api-keys'), + ]) + + const workspaceData = workspaceRes.ok ? await workspaceRes.json() : { keys: [] } + const personalData = personalRes.ok ? await personalRes.json() : { keys: [] } + + setApiKeysData({ + workspace: workspaceData.keys || [], + personal: personalData.keys || [], + }) + setKeysLoaded(true) + } catch (error) { + logger.error('Error fetching API keys:', { error }) + setKeysLoaded(true) + } + } + + const handleCreateKey = async () => { + if (!newKeyName.trim()) { + setCreateError('Please enter a name for the API key') + return + } + + try { + setIsSubmittingCreate(true) + setCreateError(null) + + const endpoint = + keyType === 'workspace' + ? `/api/workspaces/${workspaceId}/api-keys` + : '/api/users/me/api-keys' + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to create API key') + } + + const data = await response.json() + setNewKey(data.key) + setJustCreatedKeyId(data.key.id) + setShowNewKeyDialog(true) + setIsCreatingKey(false) + setNewKeyName('') + + // Refresh API keys + await fetchApiKeys() + onApiKeyCreated?.() + } catch (error: any) { + setCreateError(error.message || 'Failed to create API key') + } finally { + setIsSubmittingCreate(false) + } + } + + const handleCopyKey = async () => { + if (newKey?.key) { + await navigator.clipboard.writeText(newKey.key) + setCopySuccess(true) + setTimeout(() => setCopySuccess(false), 2000) + } + } + + if (isDeployed && deployedApiKeyDisplay) { + return ( +
+ {showLabel && ( +
+ + + + + + + +

Owner is billed for usage

+
+
+
+
+ )} +
+
+
+              {(() => {
+                const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
+                if (match) {
+                  return match[1].trim()
+                }
+                return deployedApiKeyDisplay
+              })()}
+            
+ {(() => { + const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/) + if (match) { + const type = match[2] + return ( +
+ + {type} + +
+ ) + } + return null + })()} +
+
+
+ ) + } + + return ( + <> +
+ {showLabel && ( +
+
+ + + + + + + +

Key Owner is Billed

+
+
+
+
+ {!disabled && ( + + )} +
+ )} + +
+ + {/* Create Key Dialog */} + + + + Create new API key + + {keyType === 'workspace' + ? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again." + : "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."} + + + +
+ {canCreateWorkspaceKeys && ( +
+

API Key Type

+
+ + +
+
+ )} + +
+ + { + setNewKeyName(e.target.value) + if (createError) setCreateError(null) + }} + disabled={isSubmittingCreate} + /> + {createError &&

{createError}

} +
+
+ + + { + setNewKeyName('') + setCreateError(null) + }} + > + Cancel + + { + e.preventDefault() + handleCreateKey() + }} + > + {isSubmittingCreate ? ( + <> + + Creating... + + ) : ( + 'Create' + )} + + +
+
+ + {/* New Key Dialog */} + + + + API Key Created Successfully + + Your new API key has been created. Make sure to copy it now as you won't be able to + see it again. + + + +
+ +
+ + +
+
+ + + { + setShowNewKeyDialog(false) + setNewKey(null) + setCopySuccess(false) + // Auto-select the newly created key + if (justCreatedKeyId) { + onChange(justCreatedKeyId) + setJustCreatedKeyId(null) + } + }} + > + Done + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 43ad1bad39..0aaa0abf31 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -41,11 +41,12 @@ interface ChatDeployProps { chatSubmitting: boolean setChatSubmitting: (submitting: boolean) => void onValidationChange?: (isValid: boolean) => void - onPreDeployWorkflow?: () => Promise showDeleteConfirmation?: boolean setShowDeleteConfirmation?: (show: boolean) => void onDeploymentComplete?: () => void onDeployed?: () => void + onUndeploy?: () => Promise + onVersionActivated?: () => void } interface ExistingChat { @@ -69,11 +70,12 @@ export function ChatDeploy({ chatSubmitting, setChatSubmitting, onValidationChange, - onPreDeployWorkflow, showDeleteConfirmation: externalShowDeleteConfirmation, setShowDeleteConfirmation: externalSetShowDeleteConfirmation, onDeploymentComplete, onDeployed, + onUndeploy, + onVersionActivated, }: ChatDeployProps) { const [isLoading, setIsLoading] = useState(false) const [existingChat, setExistingChat] = useState(null) @@ -97,6 +99,7 @@ export function ChatDeploy({ const { deployedUrl, deployChat } = useChatDeployment() const formRef = useRef(null) const [isIdentifierValid, setIsIdentifierValid] = useState(false) + const isFormValid = isIdentifierValid && Boolean(formData.title.trim()) && @@ -148,7 +151,6 @@ export function ChatDeploy({ : [], }) - // Set image URL if it exists if (chatDetail.customizations?.imageUrl) { setImageUrl(chatDetail.customizations.imageUrl) } @@ -178,8 +180,6 @@ export function ChatDeploy({ setChatSubmitting(true) try { - await onPreDeployWorkflow?.() - if (!validateForm()) { setChatSubmitting(false) return @@ -191,14 +191,13 @@ export function ChatDeploy({ return } - await deployChat(workflowId, formData, deploymentInfo, existingChat?.id, imageUrl) + await deployChat(workflowId, formData, null, existingChat?.id, imageUrl) onChatExistsChange?.(true) setShowSuccessView(true) onDeployed?.() + onVersionActivated?.() - // Fetch the updated chat data immediately after deployment - // This ensures existingChat is available when switching back to edit mode await fetchExistingChat() } catch (error: any) { if (error.message?.includes('identifier')) { @@ -226,13 +225,15 @@ export function ChatDeploy({ throw new Error(error.error || 'Failed to delete chat') } - // Update state + if (onUndeploy) { + await onUndeploy() + } + setExistingChat(null) setImageUrl(null) setImageUploadError(null) onChatExistsChange?.(false) - // Notify parent of successful deletion onDeploymentComplete?.() } catch (error: any) { logger.error('Failed to delete chat:', error) @@ -268,8 +269,8 @@ export function ChatDeploy({ This will permanently delete your chat deployment at{' '} {getEmailDomain()}/chat/{existingChat?.identifier} - - . + {' '} + and undeploy the workflow. All users will lose access immediately, and this action cannot be undone. @@ -324,6 +325,7 @@ export function ChatDeploy({ onValidationChange={setIsIdentifierValid} isEditingExisting={!!existingChat} /> +
- {/* Image Upload Section */}
{ setImageUrl(url) - setImageUploadError(null) // Clear error on successful upload + setImageUploadError(null) }} onError={setImageUploadError} onUploadStart={setIsImageUploading} @@ -427,7 +428,6 @@ export function ChatDeploy({ )}
- {/* Hidden delete trigger button for modal footer */} -
- + { + field.onChange(keyId) + onApiKeyChange(keyId) + }} + apiKeys={apiKeys} + onApiKeyCreated={onApiKeyCreated} + showLabel={true} + label='Select API Key' + isDeployed={isDeployed} + deployedApiKeyDisplay={deployedApiKeyDisplay} + /> )} /> - - {/* Create API Key Dialog */} - - - - Create new API key - - {keyType === 'workspace' - ? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again." - : "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."} - - - -
- {canCreateWorkspaceKeys && ( -
-

API Key Type

-
- - -
-
- )} -
-

- Enter a name for your API key to help you identify it later. -

- { - setNewKeyName(e.target.value) - if (createError) setCreateError(null) // Clear error when user types - }} - placeholder='e.g., Development, Production' - className='h-9 rounded-[8px]' - autoFocus - /> - {createError &&
{createError}
} -
-
- - - { - setNewKeyName('') - setKeyType('personal') - setCreateError(null) - }} - > - Cancel - - - -
-
- - {/* New API Key Dialog */} - { - setShowNewKeyDialog(open) - if (!open) { - setNewKey(null) - setCopySuccess(false) - } - }} - > - - - Your API key has been created - - This is the only time you will see your API key.{' '} - Copy it now and store it securely. - - - - {newKey && ( -
-
- - {newKey.key} - -
- -
- )} -
-
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index e13d4df43e..601a6f5f16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -86,8 +86,8 @@ export function DeployModal({ const [deploymentInfo, setDeploymentInfo] = useState(null) const [isLoading, setIsLoading] = useState(false) const [apiKeys, setApiKeys] = useState([]) - const [keysLoaded, setKeysLoaded] = useState(false) const [activeTab, setActiveTab] = useState('general') + const [selectedApiKeyId, setSelectedApiKeyId] = useState('') const [chatSubmitting, setChatSubmitting] = useState(false) const [apiDeployError, setApiDeployError] = useState(null) const [chatExists, setChatExists] = useState(false) @@ -106,6 +106,7 @@ export function DeployModal({ const [editValue, setEditValue] = useState('') const [isRenaming, setIsRenaming] = useState(false) const [openDropdown, setOpenDropdown] = useState(null) + const [versionToActivate, setVersionToActivate] = useState(null) const inputRef = useRef(null) useEffect(() => { @@ -212,17 +213,14 @@ export function DeployModal({ if (!open) return try { - setKeysLoaded(false) const response = await fetch('/api/users/me/api-keys') if (response.ok) { const data = await response.json() setApiKeys(data.keys || []) - setKeysLoaded(true) } } catch (error) { logger.error('Error fetching API keys:', { error }) - setKeysLoaded(true) } } @@ -257,12 +255,31 @@ export function DeployModal({ fetchApiKeys() fetchChatDeploymentInfo() setActiveTab('api') + setVersionToActivate(null) + } else { + setSelectedApiKeyId('') + setVersionToActivate(null) } }, [open, workflowId]) + useEffect(() => { + if (apiKeys.length === 0) return + + if (deploymentInfo?.apiKey) { + const matchingKey = apiKeys.find((k) => k.key === deploymentInfo.apiKey) + if (matchingKey) { + setSelectedApiKeyId(matchingKey.id) + return + } + } + + if (!selectedApiKeyId) { + setSelectedApiKeyId(apiKeys[0].id) + } + }, [deploymentInfo, apiKeys]) + useEffect(() => { async function fetchDeploymentInfo() { - // If not open or not deployed, clear info and stop if (!open || !workflowId || !isDeployed) { setDeploymentInfo(null) if (!open) { @@ -271,7 +288,6 @@ export function DeployModal({ return } - // If we already have deploymentInfo (e.g., just deployed and set locally), avoid overriding it if (deploymentInfo?.isDeployed && !needsRedeployment) { setIsLoading(false) return @@ -288,7 +304,7 @@ export function DeployModal({ const data = await response.json() const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) // Include streaming params only if outputs selected + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) setDeploymentInfo({ isDeployed: data.isDeployed, @@ -314,13 +330,20 @@ export function DeployModal({ try { setIsSubmitting(true) - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + const apiKeyToUse = data.apiKey || selectedApiKeyId + + let deployEndpoint = `/api/workflows/${workflowId}/deploy` + if (versionToActivate !== null) { + deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate` + } + + const response = await fetch(deployEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - apiKey: data.apiKey, + apiKey: apiKeyToUse, deployChatEnabled: false, }), }) @@ -330,35 +353,47 @@ export function DeployModal({ throw new Error(errorData.error || 'Failed to deploy workflow') } - const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json() + const responseData = await response.json() - setDeploymentStatus( - workflowId, - newDeployStatus, - deployedAt ? new Date(deployedAt) : undefined, - apiKey || data.apiKey - ) + const isActivating = versionToActivate !== null + const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false) + const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined + const apiKeyFromResponse = responseData.apiKey || apiKeyToUse - setNeedsRedeployment(false) - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) // Include streaming params only if outputs selected - - const newDeploymentInfo = { - isDeployed: true, - deployedAt: deployedAt, - apiKey: apiKey || data.apiKey, - endpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${apiKey || data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, - needsRedeployment: false, + setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyFromResponse) + + const matchingKey = apiKeys.find((k) => k.key === apiKeyFromResponse || k.id === apiKeyToUse) + if (matchingKey) { + setSelectedApiKeyId(matchingKey.id) } - setDeploymentInfo(newDeploymentInfo) + const isActivatingVersion = versionToActivate !== null + setNeedsRedeployment(isActivatingVersion) + if (workflowId) { + useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivatingVersion) + } await refetchDeployedState() await fetchVersions() + + const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) + if (deploymentInfoResponse.ok) { + const deploymentData = await deploymentInfoResponse.json() + const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) + + setDeploymentInfo({ + isDeployed: deploymentData.isDeployed, + deployedAt: deploymentData.deployedAt, + apiKey: deploymentData.apiKey, + endpoint: apiEndpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, + needsRedeployment: isActivatingVersion, + }) + } + + setVersionToActivate(null) + setApiDeployError(null) } catch (error: unknown) { logger.error('Error deploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' @@ -392,28 +427,9 @@ export function DeployModal({ } }, [open, workflowId]) - const activateVersion = async (version: number) => { - if (!workflowId) return - try { - setActivatingVersion(version) - const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}/activate`, { - method: 'POST', - }) - if (res.ok) { - await refetchDeployedState() - await fetchVersions() - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - if (previewVersion !== null) { - setPreviewVersion(null) - setPreviewDeployedState(null) - setPreviewing(false) - } - } - } finally { - setActivatingVersion(null) - } + const handleActivateVersion = (version: number) => { + setVersionToActivate(version) + setActiveTab('api') } const openVersionPreview = async (version: number) => { @@ -538,7 +554,6 @@ export function DeployModal({ await refetchDeployedState() await fetchVersions() - // Ensure modal status updates immediately setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev)) } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) @@ -553,34 +568,32 @@ export function DeployModal({ onOpenChange(false) } - const handleWorkflowPreDeploy = async () => { - // Always deploy to ensure a new deployment version exists - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployApiEnabled: true, - deployChatEnabled: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to deploy workflow') - } + const handlePostDeploymentUpdate = async () => { + if (!workflowId) return - const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json() + const isActivating = versionToActivate !== null - setDeploymentStatus( - workflowId, - newDeployStatus, - deployedAt ? new Date(deployedAt) : undefined, - apiKey - ) + setDeploymentStatus(workflowId, true, new Date()) + + const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) + if (deploymentInfoResponse.ok) { + const deploymentData = await deploymentInfoResponse.json() + const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) + + setDeploymentInfo({ + isDeployed: deploymentData.isDeployed, + deployedAt: deploymentData.deployedAt, + apiKey: deploymentData.apiKey, + endpoint: apiEndpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, + needsRedeployment: isActivating, + }) + } - setDeploymentInfo((prev) => (prev ? { ...prev, apiKey } : null)) + await refetchDeployedState() + await fetchVersions() + useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivating) } const handleChatFormSubmit = () => { @@ -597,365 +610,340 @@ export function DeployModal({ } return ( - - - -
- Deploy Workflow - -
-
- -
-
-
- - - + + Close + +
+ + +
+
+
+ + + +
-
-
-
- {activeTab === 'api' && ( - <> - {isDeployed ? ( - - ) : ( - <> - {apiDeployError && ( -
-
API Deployment Error
-
{apiDeployError}
+
+
+ {activeTab === 'api' && ( + <> + {versionToActivate !== null ? ( + <> + {apiDeployError && ( +
+
API Deployment Error
+
{apiDeployError}
+
+ )} + +
+
- )} - -
- + ) : isDeployed ? ( + <> + + + ) : ( + <> + {apiDeployError && ( +
+
API Deployment Error
+
{apiDeployError}
+
+ )} + +
+ +
+ + )} + + )} + + {activeTab === 'versions' && ( + <> +
Deployment Versions
+ {versionsLoading ? ( +
+ Loading deployments...
- - )} - - )} - - {activeTab === 'versions' && ( - <> -
Deployment Versions
- {versionsLoading ? ( -
- Loading deployments... -
- ) : versions.length === 0 ? ( -
- No deployments yet -
- ) : ( - <> -
- - - - - - - - - - {versions - .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) - .map((v) => ( - { - if (editingVersion !== v.version) { - openVersionPreview(v.version) - } - }} - > - - + + ))} + +
- - Version - - Deployed By - - Created - -
-
-
- {editingVersion === v.version ? ( - setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSaveRename(v.version) - } else if (e.key === 'Escape') { - e.preventDefault() - handleCancelRename() - } - }} - onBlur={() => handleSaveRename(v.version)} - className='w-full border-0 bg-transparent p-0 font-medium text-sm leading-5 outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' - maxLength={100} - disabled={isRenaming} - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' + ) : versions.length === 0 ? ( +
+ No deployments yet +
+ ) : ( + <> +
+ + + + + + + + + + {versions + .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) + .map((v) => ( + { + if (editingVersion !== v.version) { + openVersionPreview(v.version) + } + }} + > + + + - - - + + - - ))} - -
+ + Version + + Deployed By + + Created + +
+
- ) : ( - - {v.name || `v${v.version}`} +
+ {editingVersion === v.version ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveRename(v.version) + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelRename() + } + }} + onBlur={() => handleSaveRename(v.version)} + className='w-full border-0 bg-transparent p-0 font-medium text-sm leading-5 outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + disabled={isRenaming} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> + ) : ( + + {v.name || `v${v.version}`} + + )} + + + {v.deployedBy || 'Unknown'} - )} - - - {v.deployedBy || 'Unknown'} - - - - {new Date(v.createdAt).toLocaleDateString()}{' '} - {new Date(v.createdAt).toLocaleTimeString()} - - e.stopPropagation()}> - - setOpenDropdown(open ? v.version : null) - } + + + {new Date(v.createdAt).toLocaleDateString()}{' '} + {new Date(v.createdAt).toLocaleTimeString()} + + e.stopPropagation()} > - - - - event.preventDefault()} + + setOpenDropdown(open ? v.version : null) + } > - activateVersion(v.version)} - disabled={v.isActive || activatingVersion === v.version} - > - {v.isActive - ? 'Active' - : activatingVersion === v.version - ? 'Activating...' - : 'Activate'} - - openVersionPreview(v.version)} + + + + event.preventDefault()} > - Inspect - - handleStartRename(v.version, v.name)} - > - Rename - - - -
-
- {versions.length > itemsPerPage && ( -
- - Showing{' '} - {Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '} - {Math.min(currentPage * itemsPerPage, versions.length)} of{' '} - {versions.length} - -
- - -
+ openVersionPreview(v.version)} + > + {v.isActive ? 'View Active' : 'Inspect'} + + handleStartRename(v.version, v.name)} + > + Rename + + + +
- )} - - )} - - )} - - {activeTab === 'chat' && ( - { - await refetchDeployedState() - await fetchVersions() - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - }} - /> - )} + {versions.length > itemsPerPage && ( +
+ + Showing{' '} + {Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '} + {Math.min(currentPage * itemsPerPage, versions.length)} of{' '} + {versions.length} + +
+ + +
+
+ )} + + )} + + )} + + {activeTab === 'chat' && ( + <> + setVersionToActivate(null)} + /> + + )} +
-
- - {activeTab === 'api' && !isDeployed && ( -
- - - -
- )} - {activeTab === 'chat' && ( -
- + {activeTab === 'api' && (versionToActivate !== null || !isDeployed) && ( +
+ -
- {chatExists && ( - - )}
-
+ )} + + {activeTab === 'chat' && ( +
+ + +
+ {chatExists && ( + + )} + +
+
+ )} + + {previewVersion !== null && previewDeployedState && workflowId && ( + { + setPreviewVersion(null) + setPreviewDeployedState(null) + setPreviewing(false) + }} + needsRedeployment={true} + activeDeployedState={deployedState} + selectedDeployedState={previewDeployedState as WorkflowState} + selectedVersion={previewVersion} + onActivateVersion={() => { + handleActivateVersion(previewVersion) + setPreviewVersion(null) + setPreviewDeployedState(null) + setPreviewing(false) + }} + isActivating={activatingVersion === previewVersion} + selectedVersionLabel={ + versions.find((v) => v.version === previewVersion)?.name || `v${previewVersion}` + } + workflowId={workflowId} + isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive} + /> )} - - {previewVersion !== null && previewDeployedState && workflowId && ( - { - setPreviewVersion(null) - setPreviewDeployedState(null) - setPreviewing(false) - }} - needsRedeployment={true} - activeDeployedState={deployedState} - selectedDeployedState={previewDeployedState as WorkflowState} - selectedVersion={previewVersion} - onActivateVersion={() => activateVersion(previewVersion)} - isActivating={activatingVersion === previewVersion} - selectedVersionLabel={ - versions.find((v) => v.version === previewVersion)?.name || `v${previewVersion}` - } - workflowId={workflowId} - isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive} - /> - )} -
+ + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx index e42bbf4ea6..01339cf8f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx @@ -118,9 +118,15 @@ export function DeployedWorkflowModal({ Active
) : ( - +
+ +
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx index bb5eff712c..627e33b597 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -35,6 +35,7 @@ export function DeploymentControls({ const isDeployed = deploymentStatus?.isDeployed || false const workflowNeedsRedeployment = needsRedeployment + const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment const [isDeploying, _setIsDeploying] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) @@ -93,7 +94,9 @@ export function DeploymentControls({ 'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs', 'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white', 'transition-all duration-200', - isDeployed && 'text-[var(--brand-primary-hover-hex)]', + isDeployed && !isPreviousVersionActive && 'text-[var(--brand-primary-hover-hex)]', + isPreviousVersionActive && + 'border-purple-500 bg-purple-500/10 text-purple-600 dark:text-purple-400', isDisabled && 'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs' )} diff --git a/apps/sim/blocks/blocks/qdrant.ts b/apps/sim/blocks/blocks/qdrant.ts index a06743764e..7d43f4e848 100644 --- a/apps/sim/blocks/blocks/qdrant.ts +++ b/apps/sim/blocks/blocks/qdrant.ts @@ -100,17 +100,17 @@ export const QdrantBlock: BlockConfig = { condition: { field: 'operation', value: 'search' }, }, { - id: 'with_payload', - title: 'With Payload', - type: 'switch', - layout: 'full', - condition: { field: 'operation', value: 'search' }, - }, - { - id: 'with_vector', - title: 'With Vector', - type: 'switch', + id: 'search_return_data', + title: 'Return Data', + type: 'dropdown', layout: 'full', + options: [ + { label: 'Payload Only', id: 'payload_only' }, + { label: 'Vector Only', id: 'vector_only' }, + { label: 'Both Payload and Vector', id: 'both' }, + { label: 'None (IDs and scores only)', id: 'none' }, + ], + value: () => 'payload_only', condition: { field: 'operation', value: 'search' }, }, // Fetch fields @@ -142,17 +142,17 @@ export const QdrantBlock: BlockConfig = { required: true, }, { - id: 'with_payload', - title: 'With Payload', - type: 'switch', - layout: 'full', - condition: { field: 'operation', value: 'fetch' }, - }, - { - id: 'with_vector', - title: 'With Vector', - type: 'switch', + id: 'fetch_return_data', + title: 'Return Data', + type: 'dropdown', layout: 'full', + options: [ + { label: 'Payload Only', id: 'payload_only' }, + { label: 'Vector Only', id: 'vector_only' }, + { label: 'Both Payload and Vector', id: 'both' }, + { label: 'None (IDs only)', id: 'none' }, + ], + value: () => 'payload_only', condition: { field: 'operation', value: 'fetch' }, }, { @@ -194,6 +194,8 @@ export const QdrantBlock: BlockConfig = { limit: { type: 'number', description: 'Result limit' }, filter: { type: 'json', description: 'Search filter' }, ids: { type: 'json', description: 'Point identifiers' }, + search_return_data: { type: 'string', description: 'Data to return from search' }, + fetch_return_data: { type: 'string', description: 'Data to return from fetch' }, with_payload: { type: 'boolean', description: 'Include payload' }, with_vector: { type: 'boolean', description: 'Include vectors' }, }, diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts index 5b636f5d41..6d68c42537 100644 --- a/apps/sim/lib/workflows/db-helpers.ts +++ b/apps/sim/lib/workflows/db-helpers.ts @@ -1,13 +1,15 @@ import { db, + workflow, workflowBlocks, workflowDeploymentVersion, workflowEdges, workflowSubflows, } from '@sim/db' import type { InferSelectModel } from 'drizzle-orm' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' +import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' @@ -356,3 +358,131 @@ export async function migrateWorkflowToNormalizedTables( } } } + +/** + * Deploy a workflow by creating a new deployment version + */ +export async function deployWorkflow(params: { + workflowId: string + deployedBy: string // User ID of the person deploying + pinnedApiKeyId?: string + includeDeployedState?: boolean + workflowName?: string +}): Promise<{ + success: boolean + version?: number + deployedAt?: Date + currentState?: any + error?: string +}> { + const { + workflowId, + deployedBy, + pinnedApiKeyId, + includeDeployedState = false, + workflowName, + } = params + + try { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalizedData) { + return { success: false, error: 'Failed to load workflow state' } + } + + const currentState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + lastSaved: Date.now(), + } + + const now = new Date() + + const deployedVersion = await db.transaction(async (tx) => { + // Get next version number + const [{ maxVersion }] = await tx + .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + const nextVersion = Number(maxVersion) + 1 + + // Deactivate all existing versions + await tx + .update(workflowDeploymentVersion) + .set({ isActive: false }) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + // Create new deployment version + await tx.insert(workflowDeploymentVersion).values({ + id: uuidv4(), + workflowId, + version: nextVersion, + state: currentState, + isActive: true, + createdBy: deployedBy, + createdAt: now, + }) + + // Update workflow to deployed + const updateData: Record = { + isDeployed: true, + deployedAt: now, + } + + if (includeDeployedState) { + updateData.deployedState = currentState + } + + if (pinnedApiKeyId) { + updateData.pinnedApiKeyId = pinnedApiKeyId + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + return nextVersion + }) + + logger.info(`Deployed workflow ${workflowId} as v${deployedVersion}`) + + // Track deployment telemetry if workflow name is provided + if (workflowName) { + try { + const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') + + const blockTypeCounts: Record = {} + for (const block of Object.values(currentState.blocks)) { + const blockType = (block as any).type || 'unknown' + blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 + } + + trackPlatformEvent('platform.workflow.deployed', { + 'workflow.id': workflowId, + 'workflow.name': workflowName, + 'workflow.blocks_count': Object.keys(currentState.blocks).length, + 'workflow.edges_count': currentState.edges.length, + 'workflow.loops_count': Object.keys(currentState.loops).length, + 'workflow.parallels_count': Object.keys(currentState.parallels).length, + 'workflow.block_types': JSON.stringify(blockTypeCounts), + 'deployment.version': deployedVersion, + }) + } catch (telemetryError) { + logger.warn(`Failed to track deployment telemetry for ${workflowId}`, telemetryError) + } + } + + return { + success: true, + version: deployedVersion, + deployedAt: now, + currentState, + } + } catch (error) { + logger.error(`Error deploying workflow ${workflowId}:`, error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } +} diff --git a/apps/sim/lib/workflows/streaming.ts b/apps/sim/lib/workflows/streaming.ts index e7b3bb0c74..27f4342875 100644 --- a/apps/sim/lib/workflows/streaming.ts +++ b/apps/sim/lib/workflows/streaming.ts @@ -16,7 +16,13 @@ export interface StreamingConfig { export interface StreamingResponseOptions { requestId: string - workflow: { id: string; userId: string; workspaceId?: string | null; isDeployed?: boolean } + workflow: { + id: string + userId: string + workspaceId?: string | null + isDeployed?: boolean + variables?: Record + } input: any executingUserId: string streamConfig: StreamingConfig diff --git a/apps/sim/tools/qdrant/fetch_points.ts b/apps/sim/tools/qdrant/fetch_points.ts index 1c32c3b114..548ad175e8 100644 --- a/apps/sim/tools/qdrant/fetch_points.ts +++ b/apps/sim/tools/qdrant/fetch_points.ts @@ -32,6 +32,12 @@ export const fetchPointsTool: ToolConfig = { visibility: 'user-only', description: 'Array of point IDs to fetch', }, + fetch_return_data: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Data to return from fetch', + }, with_payload: { type: 'boolean', required: false, @@ -53,11 +59,38 @@ export const fetchPointsTool: ToolConfig = { 'Content-Type': 'application/json', ...(params.apiKey ? { 'api-key': params.apiKey } : {}), }), - body: (params) => ({ - ids: params.ids, - with_payload: params.with_payload, - with_vector: params.with_vector, - }), + body: (params) => { + // Calculate with_payload and with_vector from fetch_return_data if provided + let withPayload = params.with_payload ?? false + let withVector = params.with_vector ?? false + + if (params.fetch_return_data) { + switch (params.fetch_return_data) { + case 'payload_only': + withPayload = true + withVector = false + break + case 'vector_only': + withPayload = false + withVector = true + break + case 'both': + withPayload = true + withVector = true + break + case 'none': + withPayload = false + withVector = false + break + } + } + + return { + ids: params.ids, + with_payload: withPayload, + with_vector: withVector, + } + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/qdrant/search_vector.ts b/apps/sim/tools/qdrant/search_vector.ts index 126ab2b5c3..f68001913f 100644 --- a/apps/sim/tools/qdrant/search_vector.ts +++ b/apps/sim/tools/qdrant/search_vector.ts @@ -44,6 +44,12 @@ export const searchVectorTool: ToolConfig = visibility: 'user-only', description: 'Filter to apply to the search', }, + search_return_data: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Data to return from search', + }, with_payload: { type: 'boolean', required: false, @@ -66,13 +72,40 @@ export const searchVectorTool: ToolConfig = 'Content-Type': 'application/json', ...(params.apiKey ? { 'api-key': params.apiKey } : {}), }), - body: (params) => ({ - query: params.vector, - limit: params.limit ? Number.parseInt(params.limit.toString()) : 10, - filter: params.filter, - with_payload: params.with_payload, - with_vector: params.with_vector, - }), + body: (params) => { + // Calculate with_payload and with_vector from search_return_data if provided + let withPayload = params.with_payload ?? false + let withVector = params.with_vector ?? false + + if (params.search_return_data) { + switch (params.search_return_data) { + case 'payload_only': + withPayload = true + withVector = false + break + case 'vector_only': + withPayload = false + withVector = true + break + case 'both': + withPayload = true + withVector = true + break + case 'none': + withPayload = false + withVector = false + break + } + } + + return { + query: params.vector, + limit: params.limit ? Number.parseInt(params.limit.toString()) : 10, + filter: params.filter, + with_payload: withPayload, + with_vector: withVector, + } + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/qdrant/types.ts b/apps/sim/tools/qdrant/types.ts index 0d85466ce5..fc0c10257e 100644 --- a/apps/sim/tools/qdrant/types.ts +++ b/apps/sim/tools/qdrant/types.ts @@ -20,12 +20,14 @@ export interface QdrantSearchParams extends QdrantBaseParams { vector: number[] limit?: number filter?: Record + search_return_data?: string with_payload?: boolean with_vector?: boolean } export interface QdrantFetchParams extends QdrantBaseParams { ids: string[] + fetch_return_data?: string with_payload?: boolean with_vector?: boolean } From c552bb9c5f34b9561f30b10803f14ade1ad0b997 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 25 Oct 2025 17:54:27 -0700 Subject: [PATCH 4/6] fix(elevenlabs): added internal auth helper for proxy routes (#1732) * fix(elevenlabs): added internal auth helper for proxy routes * remove concurrent tests * build fix --- apps/sim/app/api/proxy/image/route.ts | 7 + apps/sim/app/api/proxy/route.ts | 15 +- apps/sim/app/api/proxy/tts/route.ts | 31 ++- apps/sim/app/api/proxy/tts/stream/route.ts | 7 + apps/sim/tools/elevenlabs/tts.ts | 10 + apps/sim/tools/index.test.ts | 278 +++++++++++---------- apps/sim/tools/index.ts | 53 ++-- apps/sim/tools/openai/image.ts | 19 +- 8 files changed, 249 insertions(+), 171 deletions(-) diff --git a/apps/sim/app/api/proxy/image/route.ts b/apps/sim/app/api/proxy/image/route.ts index fc7717b671..c4f0b91e66 100644 --- a/apps/sim/app/api/proxy/image/route.ts +++ b/apps/sim/app/api/proxy/image/route.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { validateImageUrl } from '@/lib/security/input-validation' import { generateRequestId } from '@/lib/utils' @@ -14,6 +15,12 @@ export async function GET(request: NextRequest) { const imageUrl = url.searchParams.get('url') const requestId = generateRequestId() + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.error(`[${requestId}] Authentication failed for image proxy:`, authResult.error) + return new NextResponse('Unauthorized', { status: 401 }) + } + if (!imageUrl) { logger.error(`[${requestId}] Missing 'url' parameter`) return new NextResponse('Missing URL parameter', { status: 400 }) diff --git a/apps/sim/app/api/proxy/route.ts b/apps/sim/app/api/proxy/route.ts index f4699e7234..8848ed6cdf 100644 --- a/apps/sim/app/api/proxy/route.ts +++ b/apps/sim/app/api/proxy/route.ts @@ -1,4 +1,6 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' @@ -242,12 +244,18 @@ export async function GET(request: Request) { } } -export async function POST(request: Request) { +export async function POST(request: NextRequest) { const requestId = generateRequestId() const startTime = new Date() const startTimeISO = startTime.toISOString() try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error) + return createErrorResponse('Unauthorized', 401) + } + let requestBody try { requestBody = await request.json() @@ -311,7 +319,6 @@ export async function POST(request: Request) { error: result.error || 'Unknown error', }) - // Let the main executeTool handle error transformation to avoid double transformation throw new Error(result.error || 'Tool execution failed') } @@ -319,10 +326,8 @@ export async function POST(request: Request) { const endTimeISO = endTime.toISOString() const duration = endTime.getTime() - startTime.getTime() - // Add explicit timing information directly to the response const responseWithTimingData = { ...result, - // Add timing data both at root level and in nested timing object startTime: startTimeISO, endTime: endTimeISO, duration, @@ -335,7 +340,6 @@ export async function POST(request: Request) { logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`) - // Return the response with CORS headers return formatResponse(responseWithTimingData) } catch (error: any) { logger.error(`[${requestId}] Proxy request failed`, { @@ -344,7 +348,6 @@ export async function POST(request: Request) { name: error instanceof Error ? error.name : undefined, }) - // Add timing information even to error responses const endTime = new Date() const endTimeISO = endTime.toISOString() const duration = endTime.getTime() - startTime.getTime() diff --git a/apps/sim/app/api/proxy/tts/route.ts b/apps/sim/app/api/proxy/tts/route.ts index 609ea53e4f..b9a322e2d5 100644 --- a/apps/sim/app/api/proxy/tts/route.ts +++ b/apps/sim/app/api/proxy/tts/route.ts @@ -1,4 +1,6 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { validateAlphanumericId } from '@/lib/security/input-validation' import { uploadFile } from '@/lib/uploads/storage-client' @@ -6,19 +8,25 @@ import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('ProxyTTSAPI') -export async function POST(request: Request) { +export async function POST(request: NextRequest) { try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.error('Authentication failed for TTS proxy:', authResult.error) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { text, voiceId, apiKey, modelId = 'eleven_monolingual_v1' } = body if (!text || !voiceId || !apiKey) { - return new NextResponse('Missing required parameters', { status: 400 }) + return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }) } const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255) if (!voiceIdValidation.isValid) { logger.error(`Invalid voice ID: ${voiceIdValidation.error}`) - return new NextResponse(voiceIdValidation.error, { status: 400 }) + return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 }) } logger.info('Proxying TTS request for voice:', voiceId) @@ -41,16 +49,17 @@ export async function POST(request: Request) { if (!response.ok) { logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`) - return new NextResponse(`Failed to generate TTS: ${response.status} ${response.statusText}`, { - status: response.status, - }) + return NextResponse.json( + { error: `Failed to generate TTS: ${response.status} ${response.statusText}` }, + { status: response.status } + ) } const audioBlob = await response.blob() if (audioBlob.size === 0) { logger.error('Empty audio received from ElevenLabs') - return new NextResponse('Empty audio received', { status: 422 }) + return NextResponse.json({ error: 'Empty audio received' }, { status: 422 }) } const audioBuffer = Buffer.from(await audioBlob.arrayBuffer()) @@ -67,11 +76,11 @@ export async function POST(request: Request) { } catch (error) { logger.error('Error proxying TTS:', error) - return new NextResponse( - `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + return NextResponse.json( { - status: 500, - } + error: `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } ) } } diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index a2910d3738..1f090d36d6 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -1,4 +1,5 @@ import type { NextRequest } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { validateAlphanumericId } from '@/lib/security/input-validation' @@ -7,6 +8,12 @@ const logger = createLogger('ProxyTTSStreamAPI') export async function POST(request: NextRequest) { try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.error('Authentication failed for TTS stream proxy:', authResult.error) + return new Response('Unauthorized', { status: 401 }) + } + const body = await request.json() const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body diff --git a/apps/sim/tools/elevenlabs/tts.ts b/apps/sim/tools/elevenlabs/tts.ts index 5712c64493..ee132711de 100644 --- a/apps/sim/tools/elevenlabs/tts.ts +++ b/apps/sim/tools/elevenlabs/tts.ts @@ -51,6 +51,16 @@ export const elevenLabsTtsTool: ToolConfig { const data = await response.json() + if (!response.ok || data.error) { + return { + success: false, + error: data.error || 'Unknown error occurred', + output: { + audioUrl: '', + }, + } + } + return { success: true, output: { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 92ca41f9c8..ac851b69a1 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -31,7 +31,7 @@ const createMockExecutionContext = (overrides?: Partial): Exec }) describe('Tools Registry', () => { - it.concurrent('should include all expected built-in tools', () => { + it('should include all expected built-in tools', () => { expect(Object.keys(tools).length).toBeGreaterThan(10) // Check for existence of some core tools @@ -45,7 +45,7 @@ describe('Tools Registry', () => { expect(tools.serper_search).toBeDefined() }) - it.concurrent('getTool should return the correct tool by ID', () => { + it('getTool should return the correct tool by ID', () => { const httpTool = getTool('http_request') expect(httpTool).toBeDefined() expect(httpTool?.id).toBe('http_request') @@ -57,7 +57,7 @@ describe('Tools Registry', () => { expect(gmailTool?.name).toBe('Gmail Read') }) - it.concurrent('getTool should return undefined for non-existent tool', () => { + it('getTool should return undefined for non-existent tool', () => { const nonExistentTool = getTool('non_existent_tool') expect(nonExistentTool).toBeUndefined() }) @@ -133,7 +133,7 @@ describe('Custom Tools', () => { vi.resetAllMocks() }) - it.concurrent('should get custom tool by ID', () => { + it('should get custom tool by ID', () => { const customTool = getTool('custom_custom-tool-123') expect(customTool).toBeDefined() expect(customTool?.name).toBe('Custom Weather Tool') @@ -142,7 +142,7 @@ describe('Custom Tools', () => { expect(customTool?.params.location.required).toBe(true) }) - it.concurrent('should handle non-existent custom tool', () => { + it('should handle non-existent custom tool', () => { const nonExistentTool = getTool('custom_non-existent') expect(nonExistentTool).toBeUndefined() }) @@ -193,7 +193,7 @@ describe('executeTool Function', () => { cleanupEnvVars() }) - it.concurrent('should execute a tool successfully', async () => { + it('should execute a tool successfully', async () => { const result = await executeTool( 'http_request', { @@ -241,7 +241,7 @@ describe('executeTool Function', () => { ) }) - it.concurrent('should handle non-existent tool', async () => { + it('should handle non-existent tool', async () => { // Create the mock with a matching implementation vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -254,7 +254,7 @@ describe('executeTool Function', () => { vi.restoreAllMocks() }) - it.concurrent('should handle errors from tools', async () => { + it('should handle errors from tools', async () => { // Mock a failed response global.fetch = Object.assign( vi.fn().mockImplementation(async () => { @@ -284,7 +284,7 @@ describe('executeTool Function', () => { expect(result.timing).toBeDefined() }) - it.concurrent('should add timing information to results', async () => { + it('should add timing information to results', async () => { const result = await executeTool( 'http_request', { @@ -315,58 +315,59 @@ describe('Automatic Internal Route Detection', () => { cleanupEnvVars() }) - it.concurrent( - 'should detect internal routes (URLs starting with /api/) and call them directly', - async () => { - // Mock a tool with an internal route - const mockTool = { - id: 'test_internal_tool', - name: 'Test Internal Tool', - description: 'A test tool with internal route', - version: '1.0.0', - params: {}, - request: { - url: '/api/test/endpoint', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - }, - transformResponse: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'Internal route success' }, - }), - } - - // Mock the tool registry to include our test tool - const originalTools = { ...tools } - ;(tools as any).test_internal_tool = mockTool - - // Mock fetch for the internal API call - global.fetch = Object.assign( - vi.fn().mockImplementation(async (url) => { - // Should call the internal API directly, not the proxy - expect(url).toBe('http://localhost:3000/api/test/endpoint') - return { - ok: true, - status: 200, - json: () => Promise.resolve({ success: true, data: 'test' }), - clone: vi.fn().mockReturnThis(), - } - }), - { preconnect: vi.fn() } - ) as typeof fetch + it('should detect internal routes (URLs starting with /api/) and call them directly', async () => { + // Mock a tool with an internal route + const mockTool = { + id: 'test_internal_tool', + name: 'Test Internal Tool', + description: 'A test tool with internal route', + version: '1.0.0', + params: {}, + request: { + url: '/api/test/endpoint', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'Internal route success' }, + }), + } + + // Mock the tool registry to include our test tool + const originalTools = { ...tools } + ;(tools as any).test_internal_tool = mockTool + + // Mock fetch for the internal API call + global.fetch = Object.assign( + vi.fn().mockImplementation(async (url) => { + // Should call the internal API directly, not the proxy + expect(url).toBe('http://localhost:3000/api/test/endpoint') + const responseData = { success: true, data: 'test' } + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), + clone: vi.fn().mockReturnThis(), + } + }), + { preconnect: vi.fn() } + ) as typeof fetch - const result = await executeTool('test_internal_tool', {}, false) + const result = await executeTool('test_internal_tool', {}, false) - expect(result.success).toBe(true) - expect(result.output.result).toBe('Internal route success') - expect(mockTool.transformResponse).toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output.result).toBe('Internal route success') + expect(mockTool.transformResponse).toHaveBeenCalled() - // Restore original tools - Object.assign(tools, originalTools) - } - ) + // Restore original tools + Object.assign(tools, originalTools) + }) - it.concurrent('should detect external routes (full URLs) and use proxy', async () => { + it('should detect external routes (full URLs) and use proxy', async () => { // Mock a tool with an external route const mockTool = { id: 'test_external_tool', @@ -390,14 +391,17 @@ describe('Automatic Internal Route Detection', () => { vi.fn().mockImplementation(async (url) => { // Should call the proxy, not the external API directly expect(url).toBe('http://localhost:3000/api/proxy') + const responseData = { + success: true, + output: { result: 'External route via proxy' }, + } return { ok: true, status: 200, - json: () => - Promise.resolve({ - success: true, - output: { result: 'External route via proxy' }, - }), + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), } }), { preconnect: vi.fn() } @@ -412,7 +416,7 @@ describe('Automatic Internal Route Detection', () => { Object.assign(tools, originalTools) }) - it.concurrent('should handle dynamic URLs that resolve to internal routes', async () => { + it('should handle dynamic URLs that resolve to internal routes', async () => { // Mock a tool with a dynamic URL function that returns internal route const mockTool = { id: 'test_dynamic_internal', @@ -442,10 +446,14 @@ describe('Automatic Internal Route Detection', () => { vi.fn().mockImplementation(async (url) => { // Should call the internal API directly with the resolved dynamic URL expect(url).toBe('http://localhost:3000/api/resources/123') + const responseData = { success: true, data: 'test' } return { ok: true, status: 200, - json: () => Promise.resolve({ success: true, data: 'test' }), + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), clone: vi.fn().mockReturnThis(), } }), @@ -461,7 +469,7 @@ describe('Automatic Internal Route Detection', () => { Object.assign(tools, originalTools) }) - it.concurrent('should handle dynamic URLs that resolve to external routes', async () => { + it('should handle dynamic URLs that resolve to external routes', async () => { // Mock a tool with a dynamic URL function that returns external route const mockTool = { id: 'test_dynamic_external', @@ -487,14 +495,17 @@ describe('Automatic Internal Route Detection', () => { vi.fn().mockImplementation(async (url) => { // Should call the proxy, not the external API directly expect(url).toBe('http://localhost:3000/api/proxy') + const responseData = { + success: true, + output: { result: 'Dynamic external route via proxy' }, + } return { ok: true, status: 200, - json: () => - Promise.resolve({ - success: true, - output: { result: 'Dynamic external route via proxy' }, - }), + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), } }), { preconnect: vi.fn() } @@ -509,51 +520,48 @@ describe('Automatic Internal Route Detection', () => { Object.assign(tools, originalTools) }) - it.concurrent( - 'should respect skipProxy parameter and call internal routes directly even for external URLs', - async () => { - const mockTool = { - id: 'test_skip_proxy', - name: 'Test Skip Proxy Tool', - description: 'A test tool to verify skipProxy behavior', - version: '1.0.0', - params: {}, - request: { - url: 'https://api.example.com/endpoint', - method: 'GET', - headers: () => ({ 'Content-Type': 'application/json' }), - }, - transformResponse: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'Skipped proxy, called directly' }, - }), - } - - const originalTools = { ...tools } - ;(tools as any).test_skip_proxy = mockTool - - global.fetch = Object.assign( - vi.fn().mockImplementation(async (url) => { - expect(url).toBe('https://api.example.com/endpoint') - return { - ok: true, - status: 200, - json: () => Promise.resolve({ success: true, data: 'test' }), - clone: vi.fn().mockReturnThis(), - } - }), - { preconnect: vi.fn() } - ) as typeof fetch + it('should respect skipProxy parameter and call internal routes directly even for external URLs', async () => { + const mockTool = { + id: 'test_skip_proxy', + name: 'Test Skip Proxy Tool', + description: 'A test tool to verify skipProxy behavior', + version: '1.0.0', + params: {}, + request: { + url: 'https://api.example.com/endpoint', + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ + success: true, + output: { result: 'Skipped proxy, called directly' }, + }), + } + + const originalTools = { ...tools } + ;(tools as any).test_skip_proxy = mockTool + + global.fetch = Object.assign( + vi.fn().mockImplementation(async (url) => { + expect(url).toBe('https://api.example.com/endpoint') + return { + ok: true, + status: 200, + json: () => Promise.resolve({ success: true, data: 'test' }), + clone: vi.fn().mockReturnThis(), + } + }), + { preconnect: vi.fn() } + ) as typeof fetch - const result = await executeTool('test_skip_proxy', {}, true) // skipProxy = true + const result = await executeTool('test_skip_proxy', {}, true) // skipProxy = true - expect(result.success).toBe(true) - expect(result.output.result).toBe('Skipped proxy, called directly') - expect(mockTool.transformResponse).toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output.result).toBe('Skipped proxy, called directly') + expect(mockTool.transformResponse).toHaveBeenCalled() - Object.assign(tools, originalTools) - } - ) + Object.assign(tools, originalTools) + }) }) describe('Centralized Error Handling', () => { @@ -600,7 +608,7 @@ describe('Centralized Error Handling', () => { expect(result.error).toBe(expectedError) } - it.concurrent('should extract GraphQL error format (Linear API)', async () => { + it('should extract GraphQL error format (Linear API)', async () => { await testErrorFormat( 'GraphQL', { errors: [{ message: 'Invalid query field' }] }, @@ -608,7 +616,7 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should extract X/Twitter API error format', async () => { + it('should extract X/Twitter API error format', async () => { await testErrorFormat( 'X/Twitter', { errors: [{ detail: 'Rate limit exceeded' }] }, @@ -616,15 +624,15 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should extract Hunter API error format', async () => { + it('should extract Hunter API error format', async () => { await testErrorFormat('Hunter', { errors: [{ details: 'Invalid API key' }] }, 'Invalid API key') }) - it.concurrent('should extract direct errors array (string)', async () => { + it('should extract direct errors array (string)', async () => { await testErrorFormat('Direct string array', { errors: ['Network timeout'] }, 'Network timeout') }) - it.concurrent('should extract direct errors array (object)', async () => { + it('should extract direct errors array (object)', async () => { await testErrorFormat( 'Direct object array', { errors: [{ message: 'Validation failed' }] }, @@ -632,11 +640,11 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should extract OAuth error description', async () => { + it('should extract OAuth error description', async () => { await testErrorFormat('OAuth', { error_description: 'Invalid grant' }, 'Invalid grant') }) - it.concurrent('should extract SOAP fault error', async () => { + it('should extract SOAP fault error', async () => { await testErrorFormat( 'SOAP fault', { fault: { faultstring: 'Server unavailable' } }, @@ -644,7 +652,7 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should extract simple SOAP faultstring', async () => { + it('should extract simple SOAP faultstring', async () => { await testErrorFormat( 'Simple SOAP', { faultstring: 'Authentication failed' }, @@ -652,11 +660,11 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should extract Notion/Discord message format', async () => { + it('should extract Notion/Discord message format', async () => { await testErrorFormat('Notion/Discord', { message: 'Page not found' }, 'Page not found') }) - it.concurrent('should extract Airtable error object format', async () => { + it('should extract Airtable error object format', async () => { await testErrorFormat( 'Airtable', { error: { message: 'Invalid table ID' } }, @@ -664,7 +672,7 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should extract simple error string format', async () => { + it('should extract simple error string format', async () => { await testErrorFormat( 'Simple string', { error: 'Simple error message' }, @@ -672,7 +680,7 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should fall back to HTTP status when JSON parsing fails', async () => { + it('should fall back to HTTP status when JSON parsing fails', async () => { global.fetch = Object.assign( vi.fn().mockImplementation(async () => ({ ok: false, @@ -701,7 +709,7 @@ describe('Centralized Error Handling', () => { expect(result.error).toBe('Failed to parse response from function_execute: Error: Invalid JSON') }) - it.concurrent('should handle complex nested error objects', async () => { + it('should handle complex nested error objects', async () => { await testErrorFormat( 'Complex nested', { error: { code: 400, message: 'Complex validation error', details: 'Field X is invalid' } }, @@ -709,7 +717,7 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should handle error arrays with multiple entries (take first)', async () => { + it('should handle error arrays with multiple entries (take first)', async () => { await testErrorFormat( 'Multiple errors', { errors: [{ message: 'First error' }, { message: 'Second error' }] }, @@ -717,7 +725,7 @@ describe('Centralized Error Handling', () => { ) }) - it.concurrent('should stringify complex error objects when no message found', async () => { + it('should stringify complex error objects when no message found', async () => { const complexError = { code: 500, type: 'ServerError', context: { requestId: '123' } } await testErrorFormat( 'Complex object stringify', @@ -742,7 +750,7 @@ describe('MCP Tool Execution', () => { cleanupEnvVars() }) - it.concurrent('should execute MCP tool with valid tool ID', async () => { + it('should execute MCP tool with valid tool ID', async () => { global.fetch = Object.assign( vi.fn().mockImplementation(async (url, options) => { expect(url).toBe('http://localhost:3000/api/mcp/tools/execute') @@ -787,7 +795,7 @@ describe('MCP Tool Execution', () => { expect(result.timing).toBeDefined() }) - it.concurrent('should handle MCP tool ID parsing correctly', async () => { + it('should handle MCP tool ID parsing correctly', async () => { global.fetch = Object.assign( vi.fn().mockImplementation(async (url, options) => { const body = JSON.parse(options?.body as string) @@ -818,7 +826,7 @@ describe('MCP Tool Execution', () => { ) }) - it.concurrent('should handle MCP block arguments format', async () => { + it('should handle MCP block arguments format', async () => { global.fetch = Object.assign( vi.fn().mockImplementation(async (url, options) => { const body = JSON.parse(options?.body as string) @@ -852,7 +860,7 @@ describe('MCP Tool Execution', () => { ) }) - it.concurrent('should handle agent block MCP arguments format', async () => { + it('should handle agent block MCP arguments format', async () => { global.fetch = Object.assign( vi.fn().mockImplementation(async (url, options) => { const body = JSON.parse(options?.body as string) @@ -890,7 +898,7 @@ describe('MCP Tool Execution', () => { ) }) - it.concurrent('should handle MCP tool execution errors', async () => { + it('should handle MCP tool execution errors', async () => { global.fetch = Object.assign( vi.fn().mockImplementation(async () => ({ ok: false, @@ -920,14 +928,14 @@ describe('MCP Tool Execution', () => { expect(result.timing).toBeDefined() }) - it.concurrent('should require workspaceId for MCP tools', async () => { + it('should require workspaceId for MCP tools', async () => { const result = await executeTool('mcp-123-test_tool', { param: 'value' }) expect(result.success).toBe(false) expect(result.error).toContain('Missing workspaceId in execution context for MCP tool') }) - it.concurrent('should handle invalid MCP tool ID format', async () => { + it('should handle invalid MCP tool ID format', async () => { const mockContext6 = createMockExecutionContext() const result = await executeTool( @@ -942,7 +950,7 @@ describe('MCP Tool Execution', () => { expect(result.error).toContain('Tool not found') }) - it.concurrent('should handle MCP API network errors', async () => { + it('should handle MCP API network errors', async () => { global.fetch = Object.assign(vi.fn().mockRejectedValue(new Error('Network error')), { preconnect: vi.fn(), }) as typeof fetch diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1557cb2cda..7c7d0e92ae 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -408,6 +408,38 @@ function isErrorResponse( return { isError: false } } +/** + * Add internal authentication token to headers if running on server + * @param headers - Headers object to modify + * @param isInternalRoute - Whether the target URL is an internal route + * @param requestId - Request ID for logging + * @param context - Context string for logging (e.g., toolId or 'proxy') + */ +async function addInternalAuthIfNeeded( + headers: Headers | Record, + isInternalRoute: boolean, + requestId: string, + context: string +): Promise { + if (typeof window === 'undefined') { + if (isInternalRoute) { + try { + const internalToken = await generateInternalToken() + if (headers instanceof Headers) { + headers.set('Authorization', `Bearer ${internalToken}`) + } else { + headers.Authorization = `Bearer ${internalToken}` + } + logger.info(`[${requestId}] Added internal auth token for ${context}`) + } catch (error) { + logger.error(`[${requestId}] Failed to generate internal token for ${context}:`, error) + } + } else { + logger.info(`[${requestId}] Skipping internal auth token for external URL: ${context}`) + } + } +} + /** * Handle an internal/direct tool request */ @@ -448,19 +480,7 @@ async function handleInternalRequest( } const headers = new Headers(requestParams.headers) - if (typeof window === 'undefined') { - if (isInternalRoute) { - try { - const internalToken = await generateInternalToken() - headers.set('Authorization', `Bearer ${internalToken}`) - logger.info(`[${requestId}] Added internal auth token for ${toolId}`) - } catch (error) { - logger.error(`[${requestId}] Failed to generate internal token for ${toolId}:`, error) - } - } else { - logger.info(`[${requestId}] Skipping internal auth token for external URL: ${endpointUrl}`) - } - } + await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId) // Prepare request options const requestOptions = { @@ -652,9 +672,12 @@ async function handleProxyRequest( const proxyUrl = new URL('/api/proxy', baseUrl).toString() try { + const headers: Record = { 'Content-Type': 'application/json' } + await addInternalAuthIfNeeded(headers, true, requestId, `proxy:${toolId}`) + const response = await fetch(proxyUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ toolId, params, executionContext }), }) @@ -669,9 +692,7 @@ async function handleProxyRequest( let errorMessage = `HTTP error ${response.status}: ${response.statusText}` try { - // Try to parse as JSON for more details const errorJson = JSON.parse(errorText) - // Enhanced error extraction to match internal API patterns errorMessage = // Primary error patterns errorJson.errors?.[0]?.message || diff --git a/apps/sim/tools/openai/image.ts b/apps/sim/tools/openai/image.ts index 874a5e50d3..90cf2a1f42 100644 --- a/apps/sim/tools/openai/image.ts +++ b/apps/sim/tools/openai/image.ts @@ -127,10 +127,23 @@ export const imageTool: ToolConfig = { const proxyUrl = new URL('/api/proxy/image', baseUrl) proxyUrl.searchParams.append('url', imageUrl) + const headers: Record = { + Accept: 'image/*, */*', + } + + if (typeof window === 'undefined') { + const { generateInternalToken } = await import('@/lib/auth/internal') + try { + const token = await generateInternalToken() + headers.Authorization = `Bearer ${token}` + logger.info('Added internal auth token for image proxy request') + } catch (error) { + logger.error('Failed to generate internal token for image proxy:', error) + } + } + const imageResponse = await fetch(proxyUrl.toString(), { - headers: { - Accept: 'image/*, */*', - }, + headers, cache: 'no-store', }) From 274d5e3afcb2c54ad977162a688ed8309b9e2b29 Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:30:08 -1000 Subject: [PATCH 5/6] fix(clay): fixed clay tool (#1725) * fixed clay tool * added metadata * added metadata to types * fix(clay): remove (optional) from subblock name * regen docs --- apps/docs/content/docs/en/tools/clay.mdx | 6 +-- apps/docs/content/docs/en/tools/discord.mdx | 2 +- apps/docs/content/docs/en/tools/qdrant.mdx | 2 + apps/sim/blocks/blocks/clay.ts | 12 +++-- apps/sim/tools/clay/populate.ts | 59 ++++++++++++++++----- apps/sim/tools/clay/types.ts | 7 +++ 6 files changed, 68 insertions(+), 20 deletions(-) diff --git a/apps/docs/content/docs/en/tools/clay.mdx b/apps/docs/content/docs/en/tools/clay.mdx index 2b9510d20a..81fed5c47b 100644 --- a/apps/docs/content/docs/en/tools/clay.mdx +++ b/apps/docs/content/docs/en/tools/clay.mdx @@ -214,14 +214,14 @@ Populate Clay with data from a JSON file. Enables direct communication and notif | --------- | ---- | -------- | ----------- | | `webhookURL` | string | Yes | The webhook URL to populate | | `data` | json | Yes | The data to populate | -| `authToken` | string | Yes | Auth token for Clay webhook authentication | +| `authToken` | string | No | Optional auth token for Clay webhook authentication \(most webhooks do not require this\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Operation success status | -| `output` | json | Clay populate operation results including response data from Clay webhook | +| `data` | json | Response data from Clay webhook | +| `metadata` | object | Webhook response metadata | diff --git a/apps/docs/content/docs/en/tools/discord.mdx b/apps/docs/content/docs/en/tools/discord.mdx index 1091d3ca2b..ecf653ef46 100644 --- a/apps/docs/content/docs/en/tools/discord.mdx +++ b/apps/docs/content/docs/en/tools/discord.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" params.webhookURL, method: 'POST', - headers: (params: ClayPopulateParams) => ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.authToken}`, - }), + headers: (params: ClayPopulateParams) => { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (params.authToken && params.authToken.trim() !== '') { + headers['x-clay-webhook-auth'] = params.authToken + } + + return headers + }, body: (params: ClayPopulateParams) => ({ data: params.data, }), @@ -43,27 +51,52 @@ export const clayPopulateTool: ToolConfig { const contentType = response.headers.get('content-type') - let data + const timestamp = new Date().toISOString() + // Extract response headers + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + + // Parse response body + let responseData if (contentType?.includes('application/json')) { - data = await response.json() + responseData = await response.json() } else { - data = await response.text() + responseData = await response.text() } return { success: true, output: { - data: contentType?.includes('application/json') ? data : { message: data }, + data: contentType?.includes('application/json') ? responseData : { message: responseData }, + metadata: { + status: response.status, + statusText: response.statusText, + headers: headers, + timestamp: timestamp, + contentType: contentType || 'unknown', + }, }, } }, outputs: { - success: { type: 'boolean', description: 'Operation success status' }, - output: { + data: { type: 'json', - description: 'Clay populate operation results including response data from Clay webhook', + description: 'Response data from Clay webhook', + }, + metadata: { + type: 'object', + description: 'Webhook response metadata', + properties: { + status: { type: 'number', description: 'HTTP status code' }, + statusText: { type: 'string', description: 'HTTP status text' }, + headers: { type: 'object', description: 'Response headers from Clay' }, + timestamp: { type: 'string', description: 'ISO timestamp when webhook was received' }, + contentType: { type: 'string', description: 'Content type of the response' }, + }, }, }, } diff --git a/apps/sim/tools/clay/types.ts b/apps/sim/tools/clay/types.ts index f5aeb2d552..9169ebf306 100644 --- a/apps/sim/tools/clay/types.ts +++ b/apps/sim/tools/clay/types.ts @@ -9,5 +9,12 @@ export interface ClayPopulateParams { export interface ClayPopulateResponse extends ToolResponse { output: { data: any + metadata: { + status: number + statusText: string + headers: Record + timestamp: string + contentType: string + } } } From ec2cc82b724dd121bb17a38b1c5dc9aa64c83b71 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 26 Oct 2025 18:35:20 -0700 Subject: [PATCH 6/6] feat(i18n): update translations (#1734) --- apps/docs/content/docs/de/tools/clay.mdx | 14 +++++++------- apps/docs/content/docs/de/tools/discord.mdx | 2 +- apps/docs/content/docs/de/tools/qdrant.mdx | 6 ++++-- apps/docs/content/docs/es/tools/clay.mdx | 14 +++++++------- apps/docs/content/docs/es/tools/discord.mdx | 2 +- apps/docs/content/docs/es/tools/qdrant.mdx | 12 +++++++----- apps/docs/content/docs/fr/tools/clay.mdx | 6 +++--- apps/docs/content/docs/fr/tools/discord.mdx | 2 +- apps/docs/content/docs/fr/tools/qdrant.mdx | 10 ++++++---- apps/docs/content/docs/ja/tools/clay.mdx | 14 +++++++------- apps/docs/content/docs/ja/tools/discord.mdx | 2 +- apps/docs/content/docs/ja/tools/qdrant.mdx | 2 ++ apps/docs/content/docs/zh/tools/clay.mdx | 8 ++++---- apps/docs/content/docs/zh/tools/discord.mdx | 2 +- apps/docs/content/docs/zh/tools/qdrant.mdx | 6 ++++-- apps/docs/i18n.lock | 10 +++++----- 16 files changed, 61 insertions(+), 51 deletions(-) diff --git a/apps/docs/content/docs/de/tools/clay.mdx b/apps/docs/content/docs/de/tools/clay.mdx index def872aa52..2bc156863a 100644 --- a/apps/docs/content/docs/de/tools/clay.mdx +++ b/apps/docs/content/docs/de/tools/clay.mdx @@ -207,18 +207,18 @@ Populate Clay with data from a JSON file. Enables direct communication and notif #### Input -| Parameter | Type | Required | Description | +| Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | -| `webhookURL` | string | Yes | The webhook URL to populate | -| `data` | json | Yes | The data to populate | -| `authToken` | string | Yes | Auth token for Clay webhook authentication | +| `webhookURL` | string | Ja | Die Webhook-URL, die befüllt werden soll | +| `data` | json | Ja | Die Daten, die befüllt werden sollen | +| `authToken` | string | Nein | Optionaler Auth-Token für die Clay-Webhook-Authentifizierung \(die meisten Webhooks benötigen dies nicht\) | #### Output -| Parameter | Type | Description | +| Parameter | Typ | Beschreibung | | --------- | ---- | ----------- | -| `success` | boolean | Operation success status | -| `output` | json | Clay populate operation results including response data from Clay webhook | +| `data` | json | Antwortdaten vom Clay-Webhook | +| `metadata` | object | Webhook-Antwort-Metadaten | ## Notes diff --git a/apps/docs/content/docs/de/tools/discord.mdx b/apps/docs/content/docs/de/tools/discord.mdx index f3935dbd5d..f4af9f2e8c 100644 --- a/apps/docs/content/docs/de/tools/discord.mdx +++ b/apps/docs/content/docs/de/tools/discord.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"