diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2e668f913e..ddaf0f95ee 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2452,6 +2452,56 @@ export const GeminiIcon = (props: SVGProps) => ( ) +export const VertexIcon = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + +) + export const CerebrasIcon = (props: SVGProps) => ( ) { export function ServiceNowIcon(props: SVGProps) { return ( - + ) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 5c8da998c2..56efde9f26 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -120,117 +120,117 @@ import { type IconComponent = ComponentType> export const blockTypeToIconMap: Record = { - calendly: CalendlyIcon, - mailchimp: MailchimpIcon, - postgresql: PostgresIcon, - twilio_voice: TwilioIcon, - elasticsearch: ElasticsearchIcon, - rds: RDSIcon, - translate: TranslateIcon, - dynamodb: DynamoDBIcon, - wordpress: WordpressIcon, - tavily: TavilyIcon, + zoom: ZoomIcon, + zep: ZepIcon, zendesk: ZendeskIcon, youtube: YouTubeIcon, - supabase: SupabaseIcon, - vision: EyeIcon, - zoom: ZoomIcon, - confluence: ConfluenceIcon, - arxiv: ArxivIcon, - webflow: WebflowIcon, - pinecone: PineconeIcon, - apollo: ApolloIcon, - servicenow: ServiceNowIcon, + x: xIcon, + wordpress: WordpressIcon, + wikipedia: WikipediaIcon, whatsapp: WhatsAppIcon, + webflow: WebflowIcon, + wealthbox: WealthboxIcon, + vision: EyeIcon, + video_generator: VideoIcon, typeform: TypeformIcon, - qdrant: QdrantIcon, - shopify: ShopifyIcon, - asana: AsanaIcon, + twilio_voice: TwilioIcon, + twilio_sms: TwilioIcon, + tts: TTSIcon, + trello: TrelloIcon, + translate: TranslateIcon, + thinking: BrainIcon, + telegram: TelegramIcon, + tavily: TavilyIcon, + supabase: SupabaseIcon, + stt: STTIcon, + stripe: StripeIcon, + stagehand: StagehandIcon, + ssh: SshIcon, sqs: SQSIcon, - apify: ApifyIcon, - memory: BrainIcon, - gitlab: GitLabIcon, - polymarket: PolymarketIcon, + spotify: SpotifyIcon, + smtp: SmtpIcon, + slack: SlackIcon, + shopify: ShopifyIcon, + sharepoint: MicrosoftSharepointIcon, + sftp: SftpIcon, + servicenow: ServiceNowIcon, serper: SerperIcon, - linear: LinearIcon, - exa: ExaAIIcon, - telegram: TelegramIcon, + sentry: SentryIcon, + sendgrid: SendgridIcon, + search: SearchIcon, salesforce: SalesforceIcon, - hubspot: HubspotIcon, - hunter: HunterIOIcon, - linkup: LinkupIcon, - mongodb: MongoDBIcon, - airtable: AirtableIcon, - discord: DiscordIcon, - ahrefs: AhrefsIcon, - neo4j: Neo4jIcon, - tts: TTSIcon, - jina: JinaAIIcon, - google_docs: GoogleDocsIcon, - perplexity: PerplexityIcon, - google_search: GoogleIcon, - x: xIcon, - kalshi: KalshiIcon, - google_calendar: GoogleCalendarIcon, - zep: ZepIcon, + s3: S3Icon, + resend: ResendIcon, + reddit: RedditIcon, + rds: RDSIcon, + qdrant: QdrantIcon, posthog: PosthogIcon, - grafana: GrafanaIcon, - google_slides: GoogleSlidesIcon, - microsoft_planner: MicrosoftPlannerIcon, - thinking: BrainIcon, + postgresql: PostgresIcon, + polymarket: PolymarketIcon, pipedrive: PipedriveIcon, - dropbox: DropboxIcon, - stagehand: StagehandIcon, - google_forms: GoogleFormsIcon, - file: DocumentIcon, - mistral_parse: MistralIcon, - gmail: GmailIcon, - openai: OpenAIIcon, + pinecone: PineconeIcon, + perplexity: PerplexityIcon, + parallel_ai: ParallelIcon, outlook: OutlookIcon, - incidentio: IncidentioIcon, + openai: OpenAIIcon, onedrive: MicrosoftOneDriveIcon, - resend: ResendIcon, - google_vault: GoogleVaultIcon, - sharepoint: MicrosoftSharepointIcon, - huggingface: HuggingFaceIcon, - sendgrid: SendgridIcon, - video_generator: VideoIcon, - smtp: SmtpIcon, - google_groups: GoogleGroupsIcon, - mailgun: MailgunIcon, - clay: ClayIcon, - jira: JiraIcon, - search: SearchIcon, - linkedin: LinkedInIcon, - wealthbox: WealthboxIcon, notion: NotionIcon, - elevenlabs: ElevenLabsIcon, + neo4j: Neo4jIcon, + mysql: MySQLIcon, + mongodb: MongoDBIcon, + mistral_parse: MistralIcon, microsoft_teams: MicrosoftTeamsIcon, - github: GithubIcon, - sftp: SftpIcon, - ssh: SshIcon, - google_drive: GoogleDriveIcon, - sentry: SentryIcon, - reddit: RedditIcon, - parallel_ai: ParallelIcon, - spotify: SpotifyIcon, - stripe: StripeIcon, - s3: S3Icon, - trello: TrelloIcon, + microsoft_planner: MicrosoftPlannerIcon, + microsoft_excel: MicrosoftExcelIcon, + memory: BrainIcon, mem0: Mem0Icon, + mailgun: MailgunIcon, + mailchimp: MailchimpIcon, + linkup: LinkupIcon, + linkedin: LinkedInIcon, + linear: LinearIcon, knowledge: PackageSearchIcon, + kalshi: KalshiIcon, + jira: JiraIcon, + jina: JinaAIIcon, intercom: IntercomIcon, - twilio_sms: TwilioIcon, - duckduckgo: DuckDuckGoIcon, - slack: SlackIcon, - datadog: DatadogIcon, - microsoft_excel: MicrosoftExcelIcon, + incidentio: IncidentioIcon, image_generator: ImageIcon, + hunter: HunterIOIcon, + huggingface: HuggingFaceIcon, + hubspot: HubspotIcon, + grafana: GrafanaIcon, + google_vault: GoogleVaultIcon, + google_slides: GoogleSlidesIcon, google_sheets: GoogleSheetsIcon, - wikipedia: WikipediaIcon, - cursor: CursorIcon, + google_groups: GoogleGroupsIcon, + google_forms: GoogleFormsIcon, + google_drive: GoogleDriveIcon, + google_docs: GoogleDocsIcon, + google_calendar: GoogleCalendarIcon, + google_search: GoogleIcon, + gmail: GmailIcon, + gitlab: GitLabIcon, + github: GithubIcon, firecrawl: FirecrawlIcon, - mysql: MySQLIcon, + file: DocumentIcon, + exa: ExaAIIcon, + elevenlabs: ElevenLabsIcon, + elasticsearch: ElasticsearchIcon, + dynamodb: DynamoDBIcon, + duckduckgo: DuckDuckGoIcon, + dropbox: DropboxIcon, + discord: DiscordIcon, + datadog: DatadogIcon, + cursor: CursorIcon, + confluence: ConfluenceIcon, + clay: ClayIcon, + calendly: CalendlyIcon, browser_use: BrowserUseIcon, - stt: STTIcon, + asana: AsanaIcon, + arxiv: ArxivIcon, + apollo: ApolloIcon, + apify: ApifyIcon, + airtable: AirtableIcon, + ahrefs: AhrefsIcon, } diff --git a/apps/docs/content/docs/en/tools/servicenow.mdx b/apps/docs/content/docs/en/tools/servicenow.mdx index affb455af2..8a1a8b71bb 100644 --- a/apps/docs/content/docs/en/tools/servicenow.mdx +++ b/apps/docs/content/docs/en/tools/servicenow.mdx @@ -1,6 +1,6 @@ --- title: ServiceNow -description: Create, read, update, delete, and bulk import ServiceNow records +description: Create, read, update, and delete ServiceNow records --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -10,9 +10,23 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" color="#032D42" /> +{/* MANUAL-CONTENT-START:intro */} +[ServiceNow](https://www.servicenow.com/) is a powerful cloud platform designed to streamline and automate IT service management (ITSM), workflows, and business processes across your organization. ServiceNow enables you to manage incidents, requests, tasks, users, and more using its extensive API. + +With ServiceNow, you can: + +- **Automate IT workflows**: Create, read, update, and delete records in any ServiceNow table, such as incidents, tasks, change requests, and users. +- **Integrate systems**: Connect ServiceNow with your other tools and processes for seamless automation. +- **Maintain a single source of truth**: Keep all your service and operations data organized and accessible. +- **Drive operational efficiency**: Reduce manual work and improve service quality with customizable workflows and automation. + +In Sim, the ServiceNow integration enables your agents to interact directly with your ServiceNow instance as part of their workflows. Agents can create, read, update, or delete records in any ServiceNow table and leverage ticket or user data for sophisticated automation and decision-making. This integration bridges your workflow automation and IT operations, empowering your agents to manage service requests, incidents, users, and assets without manual intervention. By connecting Sim with ServiceNow, you can automate service management tasks, improve response times, and ensure consistent, secure access to your organization's vital service data. +{/* MANUAL-CONTENT-END */} + + ## Usage Instructions -Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL. +Integrate ServiceNow into your workflow. Create, read, update, and delete records in any ServiceNow table including incidents, tasks, change requests, users, and more. @@ -27,7 +41,8 @@ Create a new record in a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) | -| `credential` | string | No | ServiceNow OAuth credential ID | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | | `fields` | json | Yes | Fields to set on the record \(JSON object\) | @@ -46,8 +61,9 @@ Read records from a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) | -| `credential` | string | No | ServiceNow OAuth credential ID | +| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name | | `sysId` | string | No | Specific record sys_id | | `number` | string | No | Record number \(e.g., INC0010001\) | @@ -70,8 +86,9 @@ Update an existing record in a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) | -| `credential` | string | No | ServiceNow OAuth credential ID | +| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name | | `sysId` | string | Yes | Record sys_id to update | | `fields` | json | Yes | Fields to update \(JSON object\) | @@ -91,8 +108,9 @@ Delete a record from a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) | -| `credential` | string | No | ServiceNow OAuth credential ID | +| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name | | `sysId` | string | Yes | Record sys_id to delete | diff --git a/apps/docs/content/docs/en/tools/translate.mdx b/apps/docs/content/docs/en/tools/translate.mdx index 8bf4b08edd..8d6c497781 100644 --- a/apps/docs/content/docs/en/tools/translate.mdx +++ b/apps/docs/content/docs/en/tools/translate.mdx @@ -50,6 +50,8 @@ Send a chat completion request to any supported LLM provider | `maxTokens` | number | No | Maximum tokens in the response | | `azureEndpoint` | string | No | Azure OpenAI endpoint URL | | `azureApiVersion` | string | No | Azure OpenAI API version | +| `vertexProject` | string | No | Google Cloud project ID for Vertex AI | +| `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) | #### Output diff --git a/apps/sim/app/(landing)/components/footer/consts.ts b/apps/sim/app/(landing)/components/footer/consts.ts index 7e55d03a2a..bfbf093e3d 100644 --- a/apps/sim/app/(landing)/components/footer/consts.ts +++ b/apps/sim/app/(landing)/components/footer/consts.ts @@ -70,6 +70,7 @@ export const FOOTER_TOOLS = [ 'Salesforce', 'SendGrid', 'Serper', + 'ServiceNow', 'SharePoint', 'Slack', 'Smtp', diff --git a/apps/sim/app/(landing)/landing.tsx b/apps/sim/app/(landing)/landing.tsx index 0099bcb0ca..32228805aa 100644 --- a/apps/sim/app/(landing)/landing.tsx +++ b/apps/sim/app/(landing)/landing.tsx @@ -2,7 +2,6 @@ import { Suspense } from 'react' import dynamic from 'next/dynamic' import { Background, Footer, Nav, StructuredData } from '@/app/(landing)/components' -// Lazy load heavy components for better initial load performance const Hero = dynamic(() => import('@/app/(landing)/components/hero/hero'), { loading: () =>
, }) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 95b3894a6d..2c61b903f1 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -38,7 +38,6 @@ vi.mock('@/lib/logs/console/logger', () => ({ })) import { db } from '@sim/db' -import { createLogger } from '@/lib/logs/console/logger' import { refreshOAuthToken } from '@/lib/oauth/oauth' import { getCredential, @@ -49,7 +48,6 @@ import { const mockDb = db as any const mockRefreshOAuthToken = refreshOAuthToken as any -const mockLogger = (createLogger as any)() describe('OAuth Utils', () => { beforeEach(() => { @@ -87,7 +85,6 @@ describe('OAuth Utils', () => { const userId = await getUserId('request-id') expect(userId).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalled() }) it('should return undefined if workflow is not found', async () => { @@ -96,7 +93,6 @@ describe('OAuth Utils', () => { const userId = await getUserId('request-id', 'nonexistent-workflow-id') expect(userId).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalled() }) }) @@ -121,7 +117,6 @@ describe('OAuth Utils', () => { const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') expect(credential).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalled() }) }) @@ -139,7 +134,6 @@ describe('OAuth Utils', () => { expect(mockRefreshOAuthToken).not.toHaveBeenCalled() expect(result).toEqual({ accessToken: 'valid-token', refreshed: false }) - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Access token is valid')) }) it('should refresh token when expired', async () => { @@ -159,13 +153,10 @@ describe('OAuth Utils', () => { const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Successfully refreshed') - ) }) it('should handle refresh token error', async () => { @@ -182,8 +173,6 @@ describe('OAuth Utils', () => { await expect( refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') ).rejects.toThrow('Failed to refresh token') - - expect(mockLogger.error).toHaveBeenCalled() }) it('should not attempt refresh if no refresh token', async () => { @@ -239,7 +228,7 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(token).toBe('new-token') @@ -251,7 +240,6 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id') expect(token).toBeNull() - expect(mockLogger.warn).toHaveBeenCalled() }) it('should return null if refresh fails', async () => { @@ -270,7 +258,6 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') expect(token).toBeNull() - expect(mockLogger.error).toHaveBeenCalled() }) }) }) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 66ea033fdb..b23cf06da3 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -132,14 +132,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise try { // Use the existing refreshOAuthToken function - // For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint - const instanceUrl = - providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined - const refreshResult = await refreshOAuthToken( - providerId, - credential.refreshToken!, - instanceUrl - ) + const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!) if (!refreshResult) { logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, { @@ -222,13 +215,9 @@ export async function refreshAccessTokenIfNeeded( if (shouldRefresh) { logger.info(`[${requestId}] Token expired, attempting to refresh for credential`) try { - // For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint - const instanceUrl = - credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined const refreshedToken = await refreshOAuthToken( credential.providerId, - credential.refreshToken!, - instanceUrl + credential.refreshToken! ) if (!refreshedToken) { @@ -300,14 +289,7 @@ export async function refreshTokenIfNeeded( } try { - // For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint - const instanceUrl = - credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined - const refreshResult = await refreshOAuthToken( - credential.providerId, - credential.refreshToken!, - instanceUrl - ) + const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!) if (!refreshResult) { logger.error(`[${requestId}] Failed to refresh token for credential`) diff --git a/apps/sim/app/api/auth/oauth2/callback/servicenow/route.ts b/apps/sim/app/api/auth/oauth2/callback/servicenow/route.ts deleted file mode 100644 index 0a84066f63..0000000000 --- a/apps/sim/app/api/auth/oauth2/callback/servicenow/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { env } from '@/lib/core/config/env' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('ServiceNowCallback') - -export const dynamic = 'force-dynamic' - -export async function GET(request: NextRequest) { - const baseUrl = getBaseUrl() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) - } - - const { searchParams } = request.nextUrl - const code = searchParams.get('code') - const state = searchParams.get('state') - const error = searchParams.get('error') - const errorDescription = searchParams.get('error_description') - - // Handle OAuth errors from ServiceNow - if (error) { - logger.error('ServiceNow OAuth error:', { error, errorDescription }) - return NextResponse.redirect( - `${baseUrl}/workspace?error=servicenow_auth_error&message=${encodeURIComponent(errorDescription || error)}` - ) - } - - const storedState = request.cookies.get('servicenow_oauth_state')?.value - const storedInstanceUrl = request.cookies.get('servicenow_instance_url')?.value - - const clientId = env.SERVICENOW_CLIENT_ID - const clientSecret = env.SERVICENOW_CLIENT_SECRET - - if (!clientId || !clientSecret) { - logger.error('ServiceNow credentials not configured') - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_config_error`) - } - - // Validate state parameter - if (!state || state !== storedState) { - logger.error('State mismatch in ServiceNow OAuth callback') - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_state_mismatch`) - } - - // Validate authorization code - if (!code) { - logger.error('No code received from ServiceNow') - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_code`) - } - - // Validate instance URL - if (!storedInstanceUrl) { - logger.error('No instance URL stored') - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_instance`) - } - - const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow` - - // Exchange authorization code for access token - const tokenResponse = await fetch(`${storedInstanceUrl}/oauth_token.do`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - redirect_uri: redirectUri, - client_id: clientId, - client_secret: clientSecret, - }).toString(), - }) - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text() - logger.error('Failed to exchange code for token:', { - status: tokenResponse.status, - body: errorText, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_token_error`) - } - - const tokenData = await tokenResponse.json() - const accessToken = tokenData.access_token - const refreshToken = tokenData.refresh_token - const expiresIn = tokenData.expires_in - // ServiceNow always grants 'useraccount' scope but returns empty string - const scope = tokenData.scope || 'useraccount' - - logger.info('ServiceNow token exchange successful:', { - hasAccessToken: !!accessToken, - hasRefreshToken: !!refreshToken, - expiresIn, - }) - - if (!accessToken) { - logger.error('No access token in response') - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_token`) - } - - // Redirect to store endpoint with token data in cookies - const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/servicenow/store`) - - const response = NextResponse.redirect(storeUrl) - - // Store token data in secure cookies for the store endpoint - response.cookies.set('servicenow_pending_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, // 1 minute - path: '/', - }) - - if (refreshToken) { - response.cookies.set('servicenow_pending_refresh_token', refreshToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - } - - response.cookies.set('servicenow_pending_instance', storedInstanceUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('servicenow_pending_scope', scope || '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - if (expiresIn) { - response.cookies.set('servicenow_pending_expires_in', expiresIn.toString(), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - } - - // Clean up OAuth state cookies - response.cookies.delete('servicenow_oauth_state') - response.cookies.delete('servicenow_instance_url') - - return response - } catch (error) { - logger.error('Error in ServiceNow OAuth callback:', error) - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_callback_error`) - } -} diff --git a/apps/sim/app/api/auth/oauth2/servicenow/store/route.ts b/apps/sim/app/api/auth/oauth2/servicenow/store/route.ts deleted file mode 100644 index 9029af8c03..0000000000 --- a/apps/sim/app/api/auth/oauth2/servicenow/store/route.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { createLogger } from '@/lib/logs/console/logger' -import { safeAccountInsert } from '@/app/api/auth/oauth/utils' - -const logger = createLogger('ServiceNowStore') - -export const dynamic = 'force-dynamic' - -export async function GET(request: NextRequest) { - const baseUrl = getBaseUrl() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn('Unauthorized attempt to store ServiceNow token') - return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) - } - - // Retrieve token data from cookies - const accessToken = request.cookies.get('servicenow_pending_token')?.value - const refreshToken = request.cookies.get('servicenow_pending_refresh_token')?.value - const instanceUrl = request.cookies.get('servicenow_pending_instance')?.value - const scope = request.cookies.get('servicenow_pending_scope')?.value - const expiresInStr = request.cookies.get('servicenow_pending_expires_in')?.value - - if (!accessToken || !instanceUrl) { - logger.error('Missing token or instance URL in cookies') - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_missing_data`) - } - - // Validate the token by fetching user info from ServiceNow - const userResponse = await fetch( - `${instanceUrl}/api/now/table/sys_user?sysparm_query=user_name=${encodeURIComponent('javascript:gs.getUserName()')}&sysparm_limit=1`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - } - ) - - // Alternative: Use the instance info endpoint instead - let accountIdentifier = instanceUrl - let userInfo: Record | null = null - - // Try to get current user info - try { - const whoamiResponse = await fetch(`${instanceUrl}/api/now/ui/user/current_user`, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (whoamiResponse.ok) { - const whoamiData = await whoamiResponse.json() - userInfo = whoamiData.result - if (userInfo?.user_sys_id) { - accountIdentifier = userInfo.user_sys_id as string - } else if (userInfo?.user_name) { - accountIdentifier = userInfo.user_name as string - } - logger.info('Retrieved ServiceNow user info', { accountIdentifier }) - } - } catch (e) { - logger.warn('Could not retrieve ServiceNow user info, using instance URL as identifier') - } - - // Calculate expiration time - const now = new Date() - const expiresIn = expiresInStr ? Number.parseInt(expiresInStr, 10) : 3600 // Default to 1 hour - const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000) - - // Check for existing ServiceNow account for this user - const existing = await db.query.account.findFirst({ - where: and(eq(account.userId, session.user.id), eq(account.providerId, 'servicenow')), - }) - - // ServiceNow always grants 'useraccount' scope but returns empty string - const effectiveScope = scope?.trim() ? scope : 'useraccount' - - const accountData = { - accessToken: accessToken, - refreshToken: refreshToken || null, - accountId: accountIdentifier, - scope: effectiveScope, - updatedAt: now, - accessTokenExpiresAt: accessTokenExpiresAt, - idToken: instanceUrl, // Store instance URL in idToken for API calls - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - logger.info('Updated existing ServiceNow account', { accountId: existing.id }) - } else { - await safeAccountInsert( - { - id: `servicenow_${session.user.id}_${Date.now()}`, - userId: session.user.id, - providerId: 'servicenow', - accountId: accountData.accountId, - accessToken: accountData.accessToken, - refreshToken: accountData.refreshToken || undefined, - accessTokenExpiresAt: accountData.accessTokenExpiresAt, - scope: accountData.scope, - idToken: accountData.idToken, - createdAt: now, - updatedAt: now, - }, - { provider: 'ServiceNow', identifier: instanceUrl } - ) - logger.info('Created new ServiceNow account') - } - - // Get return URL from cookie - const returnUrl = request.cookies.get('servicenow_return_url')?.value - - const redirectUrl = returnUrl || `${baseUrl}/workspace` - const finalUrl = new URL(redirectUrl) - finalUrl.searchParams.set('servicenow_connected', 'true') - - const response = NextResponse.redirect(finalUrl.toString()) - - // Clean up all ServiceNow cookies - response.cookies.delete('servicenow_pending_token') - response.cookies.delete('servicenow_pending_refresh_token') - response.cookies.delete('servicenow_pending_instance') - response.cookies.delete('servicenow_pending_scope') - response.cookies.delete('servicenow_pending_expires_in') - response.cookies.delete('servicenow_return_url') - - return response - } catch (error) { - logger.error('Error storing ServiceNow token:', error) - return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_store_error`) - } -} diff --git a/apps/sim/app/api/auth/servicenow/authorize/route.ts b/apps/sim/app/api/auth/servicenow/authorize/route.ts deleted file mode 100644 index a505ddd608..0000000000 --- a/apps/sim/app/api/auth/servicenow/authorize/route.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { env } from '@/lib/core/config/env' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('ServiceNowAuthorize') - -export const dynamic = 'force-dynamic' - -/** - * ServiceNow OAuth scopes - * useraccount - Default scope for user account access - * Note: ServiceNow always returns 'useraccount' in OAuth responses regardless of requested scopes. - * Table API permissions are configured at the OAuth application level in ServiceNow. - */ -const SERVICENOW_SCOPES = 'useraccount' - -/** - * Validates a ServiceNow instance URL format - */ -function isValidInstanceUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ( - parsed.protocol === 'https:' && - (parsed.hostname.endsWith('.service-now.com') || parsed.hostname.endsWith('.servicenow.com')) - ) - } catch { - return false - } -} - -export async function GET(request: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const clientId = env.SERVICENOW_CLIENT_ID - - if (!clientId) { - logger.error('SERVICENOW_CLIENT_ID not configured') - return NextResponse.json({ error: 'ServiceNow client ID not configured' }, { status: 500 }) - } - - const instanceUrl = request.nextUrl.searchParams.get('instanceUrl') - const returnUrl = request.nextUrl.searchParams.get('returnUrl') - - if (!instanceUrl) { - const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : '' - return new NextResponse( - ` - - - Connect ServiceNow Instance - - - - - -
-

Connect Your ServiceNow Instance

-

Enter your ServiceNow instance URL to continue

-
-
- - -
-

Your instance URL looks like: https://yourcompany.service-now.com

-
- - - -`, - { - headers: { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store, no-cache, must-revalidate', - }, - } - ) - } - - // Validate instance URL - if (!isValidInstanceUrl(instanceUrl)) { - logger.error('Invalid ServiceNow instance URL:', { instanceUrl }) - return NextResponse.json( - { - error: - 'Invalid ServiceNow instance URL. Must be a valid .service-now.com or .servicenow.com domain.', - }, - { status: 400 } - ) - } - - // Clean the instance URL - const parsedUrl = new URL(instanceUrl) - const cleanInstanceUrl = parsedUrl.origin - - const baseUrl = getBaseUrl() - const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow` - - const state = crypto.randomUUID() - - // ServiceNow OAuth authorization URL - const oauthUrl = - `${cleanInstanceUrl}/oauth_auth.do?` + - new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: redirectUri, - state: state, - scope: SERVICENOW_SCOPES, - }).toString() - - logger.info('Initiating ServiceNow OAuth:', { - instanceUrl: cleanInstanceUrl, - requestedScopes: SERVICENOW_SCOPES, - redirectUri, - returnUrl: returnUrl || 'not specified', - }) - - const response = NextResponse.redirect(oauthUrl) - - // Store state and instance URL in cookies for validation in callback - response.cookies.set('servicenow_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, // 10 minutes - path: '/', - }) - - response.cookies.set('servicenow_instance_url', cleanInstanceUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - if (returnUrl) { - response.cookies.set('servicenow_return_url', returnUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - } - - return response - } catch (error) { - logger.error('Error initiating ServiceNow authorization:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index c03422dd77..818defe02f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -347,13 +347,6 @@ export function OAuthRequiredModal({ return } - if (providerId === 'servicenow') { - // Pass the current URL so we can redirect back after OAuth - const returnUrl = encodeURIComponent(window.location.href) - window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}` - return - } - await client.oauth2.link({ providerId, callbackURL: window.location.href, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index dbfa49d0d2..a487fb7b5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -12,6 +12,7 @@ import { parseProvider, } from '@/lib/oauth' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' +import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' @@ -45,10 +46,14 @@ export function CredentialSelector({ const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' + const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) + const hasDependencies = dependsOn.length > 0 + + const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) + const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue const selectedId = typeof effectiveValue === 'string' ? effectiveValue : '' - // serviceId is now the canonical identifier - derive provider from it const effectiveProviderId = useMemo( () => getProviderIdFromServiceId(serviceId) as OAuthProvider, [serviceId] @@ -130,7 +135,7 @@ export function CredentialSelector({ const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && - !disabled && + !effectiveDisabled && !isPreview && !credentialsLoading @@ -230,8 +235,10 @@ export function CredentialSelector({ selectedValue={selectedId} onChange={handleComboboxChange} onOpenChange={handleOpenChange} - placeholder={label} - disabled={disabled} + placeholder={ + hasDependencies && !depsSatisfied ? 'Fill in required fields above first' : label + } + disabled={effectiveDisabled} editable={true} filterOptions={true} isLoading={credentialsLoading} diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index b18ef6be94..20e3ce8e9d 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -1,16 +1,13 @@ import { ServiceNowIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' -import { AuthMode } from '@/blocks/types' import type { ServiceNowResponse } from '@/tools/servicenow/types' export const ServiceNowBlock: BlockConfig = { type: 'servicenow', name: 'ServiceNow', - description: 'Create, read, update, delete, and bulk import ServiceNow records', - authMode: AuthMode.OAuth, - hideFromToolbar: true, + description: 'Create, read, update, and delete ServiceNow records', longDescription: - 'Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.', + 'Integrate ServiceNow into your workflow. Create, read, update, and delete records in any ServiceNow table including incidents, tasks, change requests, users, and more.', docsLink: 'https://docs.sim.ai/tools/servicenow', category: 'tools', bgColor: '#032D42', @@ -22,12 +19,12 @@ export const ServiceNowBlock: BlockConfig = { title: 'Operation', type: 'dropdown', options: [ - { label: 'Create Record', id: 'create' }, - { label: 'Read Records', id: 'read' }, - { label: 'Update Record', id: 'update' }, - { label: 'Delete Record', id: 'delete' }, + { label: 'Create Record', id: 'servicenow_create_record' }, + { label: 'Read Records', id: 'servicenow_read_record' }, + { label: 'Update Record', id: 'servicenow_update_record' }, + { label: 'Delete Record', id: 'servicenow_delete_record' }, ], - value: () => 'read', + value: () => 'servicenow_read_record', }, // Instance URL { @@ -36,17 +33,26 @@ export const ServiceNowBlock: BlockConfig = { type: 'short-input', placeholder: 'https://instance.service-now.com', required: true, - description: 'Your ServiceNow instance URL', + description: 'Your ServiceNow instance URL (e.g., https://yourcompany.service-now.com)', }, - // OAuth Credential + // Username { - id: 'credential', - title: 'ServiceNow Account', - type: 'oauth-input', - serviceId: 'servicenow', - requiredScopes: ['useraccount'], - placeholder: 'Select ServiceNow account', + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Enter your ServiceNow username', + required: true, + description: 'ServiceNow user with web service access', + }, + // Password + { + id: 'password', + title: 'Password', + type: 'short-input', + placeholder: 'Enter your ServiceNow password', + password: true, required: true, + description: 'Password for the ServiceNow user', }, // Table Name { @@ -64,7 +70,7 @@ export const ServiceNowBlock: BlockConfig = { type: 'code', language: 'json', placeholder: '{\n "short_description": "Issue description",\n "priority": "1"\n}', - condition: { field: 'operation', value: 'create' }, + condition: { field: 'operation', value: 'servicenow_create_record' }, required: true, wandConfig: { enabled: true, @@ -97,21 +103,21 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Record sys_id', type: 'short-input', placeholder: 'Specific record sys_id (optional)', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, }, { id: 'number', title: 'Record Number', type: 'short-input', placeholder: 'e.g., INC0010001 (optional)', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, }, { id: 'query', title: 'Query String', type: 'short-input', placeholder: 'active=true^priority=1', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, description: 'ServiceNow encoded query string', }, { @@ -119,14 +125,14 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Limit', type: 'short-input', placeholder: '10', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, }, { id: 'fields', title: 'Fields to Return', type: 'short-input', placeholder: 'number,short_description,priority', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, description: 'Comma-separated list of fields', }, // Update-specific: sysId and fields @@ -135,7 +141,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Record sys_id', type: 'short-input', placeholder: 'Record sys_id to update', - condition: { field: 'operation', value: 'update' }, + condition: { field: 'operation', value: 'servicenow_update_record' }, required: true, }, { @@ -144,7 +150,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti type: 'code', language: 'json', placeholder: '{\n "state": "2",\n "assigned_to": "user.sys_id"\n}', - condition: { field: 'operation', value: 'update' }, + condition: { field: 'operation', value: 'servicenow_update_record' }, required: true, wandConfig: { enabled: true, @@ -176,7 +182,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st title: 'Record sys_id', type: 'short-input', placeholder: 'Record sys_id to delete', - condition: { field: 'operation', value: 'delete' }, + condition: { field: 'operation', value: 'servicenow_delete_record' }, required: true, }, ], @@ -188,60 +194,26 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st 'servicenow_delete_record', ], config: { - tool: (params) => { - switch (params.operation) { - case 'create': - return 'servicenow_create_record' - case 'read': - return 'servicenow_read_record' - case 'update': - return 'servicenow_update_record' - case 'delete': - return 'servicenow_delete_record' - default: - throw new Error(`Invalid ServiceNow operation: ${params.operation}`) - } - }, + tool: (params) => params.operation, params: (params) => { - const { operation, fields, records, credential, ...rest } = params - - // Parse JSON fields if provided - let parsedFields: Record | undefined - if (fields && (operation === 'create' || operation === 'update')) { - try { - parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields - } catch (error) { - throw new Error( - `Invalid JSON in fields: ${error instanceof Error ? error.message : String(error)}` - ) - } - } + const { operation, fields, ...rest } = params + const isCreateOrUpdate = + operation === 'servicenow_create_record' || operation === 'servicenow_update_record' - // Validate OAuth credential - if (!credential) { - throw new Error('ServiceNow account credential is required') + if (fields && isCreateOrUpdate) { + const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields + return { ...rest, fields: parsedFields } } - // Build params - const baseParams: Record = { - ...rest, - credential, - } - - if (operation === 'create' || operation === 'update') { - return { - ...baseParams, - fields: parsedFields, - } - } - return baseParams + return rest }, }, }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, instanceUrl: { type: 'string', description: 'ServiceNow instance URL' }, - credential: { type: 'string', description: 'ServiceNow OAuth credential ID' }, + username: { type: 'string', description: 'ServiceNow username' }, + password: { type: 'string', description: 'ServiceNow password' }, tableName: { type: 'string', description: 'Table name' }, sysId: { type: 'string', description: 'Record sys_id' }, number: { type: 'string', description: 'Record number' }, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 6c7f641382..ddaf0f95ee 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3387,17 +3387,14 @@ export function SalesforceIcon(props: SVGProps) { export function ServiceNowIcon(props: SVGProps) { return ( - + ) diff --git a/apps/sim/hooks/queries/oauth-connections.ts b/apps/sim/hooks/queries/oauth-connections.ts index 504e2f8816..f4e5eef3f9 100644 --- a/apps/sim/hooks/queries/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth-connections.ts @@ -28,7 +28,7 @@ export interface ServiceInfo extends OAuthServiceConfig { function defineServices(): ServiceInfo[] { const servicesList: ServiceInfo[] = [] - Object.values(OAUTH_PROVIDERS).forEach((provider) => { + Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => { Object.values(provider.services).forEach((service) => { servicesList.push({ ...service, @@ -142,13 +142,6 @@ export function useConnectOAuthService() { return { success: true } } - // ServiceNow requires a custom OAuth flow with instance URL input - if (providerId === 'servicenow') { - const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}` - return { success: true } - } - await client.oauth2.link({ providerId, callbackURL, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 0a16e81ed6..fa3027a3cf 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -237,8 +237,6 @@ export const env = createEnv({ WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret - SERVICENOW_CLIENT_ID: z.string().optional(), // ServiceNow OAuth client ID - SERVICENOW_CLIENT_SECRET: z.string().optional(), // ServiceNow OAuth client secret // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 3ae4a35ac2..7516732a2d 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -29,7 +29,6 @@ import { PipedriveIcon, RedditIcon, SalesforceIcon, - ServiceNowIcon, ShopifyIcon, SlackIcon, SpotifyIcon, @@ -70,7 +69,6 @@ export type OAuthProvider = | 'salesforce' | 'linkedin' | 'shopify' - | 'servicenow' | 'zoom' | 'wordpress' | 'spotify' @@ -113,7 +111,6 @@ export type OAuthService = | 'salesforce' | 'linkedin' | 'shopify' - | 'servicenow' | 'zoom' | 'wordpress' | 'spotify' @@ -621,23 +618,6 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'shopify', }, - servicenow: { - id: 'servicenow', - name: 'ServiceNow', - icon: (props) => ServiceNowIcon(props), - services: { - servicenow: { - id: 'servicenow', - name: 'ServiceNow', - description: 'Manage incidents, tasks, and records in your ServiceNow instance.', - providerId: 'servicenow', - icon: (props) => ServiceNowIcon(props), - baseProviderIcon: (props) => ServiceNowIcon(props), - scopes: ['useraccount'], - }, - }, - defaultService: 'servicenow', - }, slack: { id: 'slack', name: 'Slack', @@ -1507,21 +1487,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } - case 'servicenow': { - // ServiceNow OAuth - token endpoint is instance-specific - // This is a placeholder; actual token endpoint is set during authorization - const { clientId, clientSecret } = getCredentials( - env.SERVICENOW_CLIENT_ID, - env.SERVICENOW_CLIENT_SECRET - ) - return { - tokenEndpoint: '', // Instance-specific, set during authorization - clientId, - clientSecret, - useBasicAuth: false, - supportsRefreshTokenRotation: true, - } - } case 'zoom': { const { clientId, clientSecret } = getCredentials(env.ZOOM_CLIENT_ID, env.ZOOM_CLIENT_SECRET) return { @@ -1600,36 +1565,20 @@ function buildAuthRequest( * This is a server-side utility function to refresh OAuth tokens * @param providerId The provider ID (e.g., 'google-drive') * @param refreshToken The refresh token to use - * @param instanceUrl Optional instance URL for providers with instance-specific endpoints (e.g., ServiceNow) * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ export async function refreshOAuthToken( providerId: string, - refreshToken: string, - instanceUrl?: string + refreshToken: string ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { - // Get the provider from the providerId (e.g., 'google-drive' -> 'google') const provider = providerId.split('-')[0] - // Get provider configuration const config = getProviderAuthConfig(provider) - // For ServiceNow, the token endpoint is instance-specific - let tokenEndpoint = config.tokenEndpoint - if (provider === 'servicenow') { - if (!instanceUrl) { - logger.error('ServiceNow token refresh requires instance URL') - return null - } - tokenEndpoint = `${instanceUrl.replace(/\/$/, '')}/oauth_token.do` - } - - // Build authentication request const { headers, bodyParams } = buildAuthRequest(config, refreshToken) - // Refresh the token - const response = await fetch(tokenEndpoint, { + const response = await fetch(config.tokenEndpoint, { method: 'POST', headers, body: new URLSearchParams(bodyParams).toString(), @@ -1639,7 +1588,6 @@ export async function refreshOAuthToken( const errorText = await response.text() let errorData = errorText - // Try to parse the error as JSON for better diagnostics try { errorData = JSON.parse(errorText) } catch (_e) { @@ -1663,18 +1611,14 @@ export async function refreshOAuthToken( const data = await response.json() - // Extract token and expiration (different providers may use different field names) const accessToken = data.access_token - // Handle refresh token rotation for providers that support it let newRefreshToken = null if (config.supportsRefreshTokenRotation && data.refresh_token) { newRefreshToken = data.refresh_token logger.info(`Received new refresh token from ${provider}`) } - // Get expiration time - use provider's value or default to 1 hour (3600 seconds) - // Different providers use different names for this field const expiresIn = data.expires_in || data.expiresIn || 3600 if (!accessToken) { diff --git a/apps/sim/tools/servicenow/create_record.ts b/apps/sim/tools/servicenow/create_record.ts index a8ee81e072..ec43c9b245 100644 --- a/apps/sim/tools/servicenow/create_record.ts +++ b/apps/sim/tools/servicenow/create_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowCreateParams, ServiceNowCreateResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowCreateRecordTool') @@ -10,11 +11,6 @@ export const createRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -53,11 +54,11 @@ export const createRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), 'Content-Type': 'application/json', Accept: 'application/json', } diff --git a/apps/sim/tools/servicenow/delete_record.ts b/apps/sim/tools/servicenow/delete_record.ts index 25021dbca8..135133d632 100644 --- a/apps/sim/tools/servicenow/delete_record.ts +++ b/apps/sim/tools/servicenow/delete_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowDeleteParams, ServiceNowDeleteResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowDeleteRecordTool') @@ -10,23 +11,24 @@ export const deleteRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -53,11 +54,11 @@ export const deleteRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), Accept: 'application/json', } }, diff --git a/apps/sim/tools/servicenow/read_record.ts b/apps/sim/tools/servicenow/read_record.ts index 93b81c06bd..7f1840a17a 100644 --- a/apps/sim/tools/servicenow/read_record.ts +++ b/apps/sim/tools/servicenow/read_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowReadParams, ServiceNowReadResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowReadRecordTool') @@ -10,23 +11,24 @@ export const readRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -80,10 +81,13 @@ export const readRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), Accept: 'application/json', } }, diff --git a/apps/sim/tools/servicenow/types.ts b/apps/sim/tools/servicenow/types.ts index 07a6c073ea..d96f0711f0 100644 --- a/apps/sim/tools/servicenow/types.ts +++ b/apps/sim/tools/servicenow/types.ts @@ -7,12 +7,10 @@ export interface ServiceNowRecord { } export interface ServiceNowBaseParams { - instanceUrl?: string + instanceUrl: string + username: string + password: string tableName: string - // OAuth fields (injected by the system when using OAuth) - credential?: string - accessToken?: string - idToken?: string // Stores the instance URL from OAuth } export interface ServiceNowCreateParams extends ServiceNowBaseParams { diff --git a/apps/sim/tools/servicenow/update_record.ts b/apps/sim/tools/servicenow/update_record.ts index 629468e7d0..11626ad836 100644 --- a/apps/sim/tools/servicenow/update_record.ts +++ b/apps/sim/tools/servicenow/update_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowUpdateParams, ServiceNowUpdateResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowUpdateRecordTool') @@ -10,23 +11,24 @@ export const updateRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -59,11 +60,11 @@ export const updateRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), 'Content-Type': 'application/json', Accept: 'application/json', } diff --git a/apps/sim/tools/servicenow/utils.ts b/apps/sim/tools/servicenow/utils.ts new file mode 100644 index 0000000000..486f2266fe --- /dev/null +++ b/apps/sim/tools/servicenow/utils.ts @@ -0,0 +1,10 @@ +/** + * Creates a Basic Authentication header from username and password + * @param username ServiceNow username + * @param password ServiceNow password + * @returns Base64 encoded Basic Auth header value + */ +export function createBasicAuthHeader(username: string, password: string): string { + const credentials = Buffer.from(`${username}:${password}`).toString('base64') + return `Basic ${credentials}` +}