Skip to content

Commit 3e5200f

Browse files
authored
feat(apollo): add optional OAuth connections (#9922)
* feat(apollo): add optional OAuth connections * fix(apollo): gate OAuth setup on client availability * fix(apollo): respect workspace policy for OAuth setup
1 parent d87daa7 commit 3e5200f

49 files changed

Lines changed: 642 additions & 83 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/integrations/apollo.mdx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,50 @@ import { ReferenceDetails } from "@/components/ui/reference-details"
1111

1212
{/* MANUAL-CONTENT-START:intro */}
1313
Use [Apollo.io](https://apollo.io/) in Sim to search and enrich people and companies, manage CRM contacts and accounts, add contacts to sequences, and create follow-up tasks. Single-record and bulk enrichment operations are documented below.
14+
15+
Connect an Apollo account with OAuth, or save an API key as an Apollo credential. Existing API-key connections and workflows continue to work. OAuth requires an Apollo administrator or a user with permission to authorize third-party apps. Your Apollo plan, permissions, and credits still apply.
16+
17+
For self-hosting, register a public partner OAuth app in the [Apollo developer portal](https://developer.apollo.io/), configure `APOLLO_CLIENT_ID` and `APOLLO_CLIENT_SECRET`, and register `https://<your-domain>/api/auth/oauth2/callback/apollo` as a redirect URL. Enable the scopes listed below. API-key credentials remain available without an OAuth client.
1418
{/* MANUAL-CONTENT-END */}
1519

20+
## OAuth scopes [#oauth-scopes]
21+
22+
Sim requests these scopes when someone connects an Apollo account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup.
23+
24+
| Setting | Value |
25+
| ------- | ----- |
26+
| Redirect URI | `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/apollo` |
27+
| Environment variables | `APOLLO_CLIENT_ID`, `APOLLO_CLIENT_SECRET` |
28+
29+
| Scope | Description |
30+
| ----- | ----------- |
31+
| `read_user_profile` | View your Apollo profile |
32+
| `mixed_people_api_search` | Search Apollo people |
33+
| `people_match` | Enrich a person in Apollo |
34+
| `people_bulk_match` | Enrich people in bulk in Apollo |
35+
| `mixed_companies_search` | Search Apollo companies |
36+
| `organizations_enrich` | Enrich an Apollo organization |
37+
| `organizations_bulk_enrich` | Enrich organizations in bulk in Apollo |
38+
| `contact_write` | Create Apollo contacts |
39+
| `contact_update` | Update Apollo contacts |
40+
| `contacts_search` | Search Apollo contacts |
41+
| `contacts_bulk_create` | Create Apollo contacts in bulk |
42+
| `contacts_bulk_update` | Update Apollo contacts in bulk |
43+
| `account_write` | Create Apollo accounts |
44+
| `account_update` | Update Apollo accounts |
45+
| `accounts_search` | Search Apollo accounts |
46+
| `account_bulk_create` | Create Apollo accounts in bulk |
47+
| `account_stages_update` | Update Apollo account stages |
48+
| `opportunity_write` | Create Apollo deals |
49+
| `opportunities_list` | List Apollo deals |
50+
| `opportunity_read` | View Apollo deals |
51+
| `opportunity_update` | Update Apollo deals |
52+
| `emailer_campaigns_search` | Search Apollo sequences |
53+
| `emailer_campaigns_add_contact_ids` | Add contacts to Apollo sequences |
54+
| `tasks_create` | Create Apollo tasks |
55+
| `tasks_list` | Search Apollo tasks |
56+
| `email_accounts_list` | View Apollo email accounts |
57+
1658
## Use the block [#usage-instructions]
1759

1860
Search for people and companies, enrich contact data, manage your CRM contacts and accounts, add contacts to sequences, and create tasks.

‎apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ GitHub workflow blocks and knowledge-base token connections continue to use pers
393393
| Help Scout | `HELPSCOUT_CLIENT_ID` / `HELPSCOUT_CLIENT_SECRET` | `helpscout` |
394394
| HubSpot | `HUBSPOT_CLIENT_ID` / `HUBSPOT_CLIENT_SECRET` | `hubspot` |
395395
| Salesforce | `SALESFORCE_CLIENT_ID` / `SALESFORCE_CLIENT_SECRET` | `salesforce` |
396+
| Apollo | `APOLLO_CLIENT_ID` / `APOLLO_CLIENT_SECRET` | `apollo` |
396397
| Pipedrive | `PIPEDRIVE_CLIENT_ID` / `PIPEDRIVE_CLIENT_SECRET` | `pipedrive` |
397398
| Attio | `ATTIO_CLIENT_ID` / `ATTIO_CLIENT_SECRET` | `attio` |
398399
| Zoho Desk | `ZOHO_CLIENT_ID` / `ZOHO_CLIENT_SECRET` | `zoho-desk` |

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18513,6 +18513,7 @@
1851318513
"ramp",
1851418514
"helpscout",
1851518515
"hubspot",
18516+
"apollo",
1851618517
"linkedin",
1851718518
"instagram",
1851818519
"salesforce",

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx‎

Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow
2828
import { BrandIcon } from '@/blocks/brand-icon'
2929
import type { SubBlockConfig } from '@/blocks/types'
3030
import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials'
31+
import { useIntegrationAvailability } from '@/hooks/queries/integration-availability'
3132
import { useOAuthCredentials } from '@/hooks/queries/oauth/credentials'
3233
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
34+
import { useIntegrationAccess } from '@/hooks/use-integration-access'
3335
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
36+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
3437

3538
interface CredentialSelectorProps {
3639
blockId: string
@@ -57,6 +60,7 @@ export function CredentialSelector({
5760
const [editingValue, setEditingValue] = useState('')
5861
const [isEditing, setIsEditing] = useState(false)
5962
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
63+
const blockType = useWorkflowStore((state) => state.blocks[blockId]?.type)
6064
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
6165

6266
const label = subBlock.placeholder || 'Select credential'
@@ -100,6 +104,20 @@ export function CredentialSelector({
100104

101105
const credentialKind = subBlock.credentialKind
102106
const isMergedKinds = credentialKind === 'any'
107+
const { getAccess } = useIntegrationAccess(workspaceId, {
108+
enabled: !isAllCredentials && credentialKind !== 'service-account',
109+
})
110+
const { data: integrationAvailability, isSuccess: isAvailabilityReady } =
111+
useIntegrationAvailability({
112+
enabled: !isAllCredentials && credentialKind !== 'service-account',
113+
})
114+
const oauthConnectAvailable =
115+
Boolean(blockType && getAccess(blockType) === 'available') &&
116+
isAvailabilityReady &&
117+
(integrationAvailability?.oauthServiceAvailability.some(
118+
({ providerId, available }) => providerId === effectiveProviderId && available
119+
) ??
120+
false)
103121

104122
const credentials = useMemo(() => {
105123
// A service-account picker lists only the reusable service-account
@@ -233,8 +251,9 @@ export function CredentialSelector({
233251
setShowSetupModal(true)
234252
return
235253
}
254+
if (!oauthConnectAvailable) return
236255
setShowConnectModal(true)
237-
}, [credentialKind])
256+
}, [credentialKind, oauthConnectAvailable])
238257

239258
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
240259
const { baseProvider } = parseProvider(providerName)
@@ -278,7 +297,9 @@ export function CredentialSelector({
278297
// Suppress the setup action when the service-account flow is preview-gated
279298
// for this viewer (a custom Slack bot needs slack_v2) — existing accounts
280299
// above stay selectable.
281-
if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) {
300+
if (
301+
credentialKind === 'service-account' ? !serviceAccountConnectHidden : oauthConnectAvailable
302+
) {
282303
options.push({
283304
label:
284305
credentialKind === 'service-account'
@@ -302,6 +323,7 @@ export function CredentialSelector({
302323
credentialKind,
303324
subBlock.credentialLabels,
304325
serviceAccountConnectHidden,
326+
oauthConnectAvailable,
305327
serviceAccountTarget,
306328
provider,
307329
getProviderIcon,
@@ -323,11 +345,15 @@ export function CredentialSelector({
323345
section: labels?.oauthGroup ?? `${getProviderName(provider)} accounts`,
324346
items: [
325347
...credentials.filter((c) => c.type !== 'service_account').map(toOption),
326-
{
327-
label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`,
328-
value: '__connect_account__',
329-
iconElement: <SquareArrowUpRight className='size-3' />,
330-
},
348+
...(oauthConnectAvailable
349+
? [
350+
{
351+
label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`,
352+
value: '__connect_account__',
353+
iconElement: <SquareArrowUpRight className='size-3' />,
354+
},
355+
]
356+
: []),
331357
],
332358
},
333359
{
@@ -355,6 +381,7 @@ export function CredentialSelector({
355381
subBlock.credentialLabels,
356382
credentials,
357383
serviceAccountConnectHidden,
384+
oauthConnectAvailable,
358385
serviceAccountTarget,
359386
provider,
360387
getProviderIcon,
@@ -407,10 +434,6 @@ export function CredentialSelector({
407434
const handleComboboxChange = useCallback(
408435
(value: string) => {
409436
if (value === '__connect_account__') {
410-
if (isMergedKinds) {
411-
setShowConnectModal(true)
412-
return
413-
}
414437
handleAddCredential()
415438
return
416439
}
@@ -432,14 +455,7 @@ export function CredentialSelector({
432455
setIsEditing(true)
433456
setEditingValue(value)
434457
},
435-
[
436-
isAllCredentials,
437-
isMergedKinds,
438-
allWorkspaceCredentials,
439-
credentials,
440-
handleAddCredential,
441-
handleSelect,
442-
]
458+
[isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect]
443459
)
444460

445461
return (
@@ -470,31 +486,32 @@ export function CredentialSelector({
470486
<span className='mr-1.5 inline-block size-[6px] rounded-xs bg-[var(--caution)]' />
471487
{dataversePolicy.message}
472488
</div>
473-
{!dataversePolicy.hasInvalidEnvironment && (
474-
<Chip
475-
variant='primary'
476-
fullWidth
477-
onClick={() => {
478-
if (dataversePolicy.requiresSeparateCredential) {
479-
setShowConnectModal(true)
480-
return
481-
}
482-
writeOAuthReturnContext({
483-
origin: 'workflow',
484-
workflowId: activeWorkflowId || '',
485-
displayName: selectedCredential?.name ?? getProviderName(provider),
486-
providerId: effectiveProviderId,
487-
preCount: credentials.filter((c) => c.type !== 'service_account').length,
488-
workspaceId,
489-
reconnect: true,
490-
requestedAt: Date.now(),
491-
})
492-
setShowOAuthModal(true)
493-
}}
494-
>
495-
{dataversePolicy.actionLabel}
496-
</Chip>
497-
)}
489+
{!dataversePolicy.hasInvalidEnvironment &&
490+
(!dataversePolicy.requiresSeparateCredential || oauthConnectAvailable) && (
491+
<Chip
492+
variant='primary'
493+
fullWidth
494+
onClick={() => {
495+
if (dataversePolicy.requiresSeparateCredential) {
496+
setShowConnectModal(true)
497+
return
498+
}
499+
writeOAuthReturnContext({
500+
origin: 'workflow',
501+
workflowId: activeWorkflowId || '',
502+
displayName: selectedCredential?.name ?? getProviderName(provider),
503+
providerId: effectiveProviderId,
504+
preCount: credentials.filter((c) => c.type !== 'service_account').length,
505+
workspaceId,
506+
reconnect: true,
507+
requestedAt: Date.now(),
508+
})
509+
setShowOAuthModal(true)
510+
}}
511+
>
512+
{dataversePolicy.actionLabel}
513+
</Chip>
514+
)}
498515
</div>
499516
)}
500517

‎apps/sim/blocks/blocks/apollo.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,5 +1600,6 @@ export const ApolloV2Block: BlockConfig = {
16001600
type: 'oauth-input',
16011601
serviceId: 'apollo',
16021602
title: 'Apollo Credential',
1603+
credentialKind: 'any',
16031604
}),
16041605
}

‎apps/sim/blocks/utils.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ interface SavedCredentialPicker {
6262
type: 'oauth-input'
6363
serviceId: SavedCredentialService
6464
title: string
65+
credentialKind?: 'service-account' | 'any'
6566
}
6667

6768
interface SavedCredentialOptions {
@@ -160,7 +161,7 @@ export function withSavedCredential(
160161
0,
161162
{
162163
...picker,
163-
credentialKind: 'service-account',
164+
credentialKind: picker.credentialKind ?? 'service-account',
164165
canonicalParamId: 'oauthCredential',
165166
mode: 'basic',
166167
// 'AWS Credential' → 'Select AWS credential': sentence case, brand casing kept.

‎apps/sim/lib/api/contracts/v2/credentials.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ export const V2_OAUTH_CONNECTION_PROVIDER_IDS = [
305305
'ramp',
306306
'helpscout',
307307
'hubspot',
308+
'apollo',
308309
'linkedin',
309310
'instagram',
310311
'salesforce',

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { db } from '@sim/db'
55
import * as schema from '@sim/db/schema'
66
import { createLogger, setRequestAuth } from '@sim/logger'
77
import { getErrorMessage, toError } from '@sim/utils/errors'
8+
import { isRecordLike } from '@sim/utils/object'
89
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
910
import {
1011
APIError,
@@ -159,6 +160,7 @@ import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
159160
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
160161
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
161162
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
163+
import { formatApolloAuthorizationUrl } from '@/lib/oauth/apollo'
162164
import { clearRecordedRevocation } from '@/lib/oauth/credential-service'
163165
import {
164166
getMicrosoftRefreshTokenExpiry,
@@ -1242,6 +1244,13 @@ export const auth = betterAuth({
12421244
}
12431245
}),
12441246
after: createAuthMiddleware(async (ctx) => {
1247+
if (ctx.path === '/oauth2/link' && ctx.body?.providerId === 'apollo') {
1248+
const returned = ctx.context.returned
1249+
if (isRecordLike(returned) && typeof returned.url === 'string') {
1250+
ctx.context.returned = { ...returned, url: formatApolloAuthorizationUrl(returned.url) }
1251+
}
1252+
}
1253+
12451254
if (isBillingEnabled) await recordCustomerRestoreAfterHook(ctx)
12461255

12471256
const recordFinalAuthenticationResult = async (

‎apps/sim/lib/auth/connectors/providers.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
readResponseTextWithLimit,
1616
} from '@/lib/core/utils/stream-limits'
1717
import { getBaseUrl } from '@/lib/core/utils/urls'
18+
import { createApolloProvider } from '@/lib/oauth/apollo'
1819
import { exchangeAtlassianAuthorizationCode } from '@/lib/oauth/atlassian-token'
1920
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
2021
import { createGitHubRepositoriesProvider } from '@/lib/oauth/github-repositories'
@@ -1042,6 +1043,13 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
10421043
},
10431044
},
10441045

1046+
createApolloProvider({
1047+
clientId: env.APOLLO_CLIENT_ID as string,
1048+
clientSecret: env.APOLLO_CLIENT_SECRET as string,
1049+
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/apollo`,
1050+
scopes: getCanonicalScopesForProvider('apollo'),
1051+
}),
1052+
10451053
{
10461054
providerId: 'pipedrive',
10471055
clientId: env.PIPEDRIVE_CLIENT_ID as string,

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,8 @@ export const env = createEnv({
564564
ZOHO_CLIENT_SECRET: z.string().optional(), // Zoho OAuth client secret (Zoho Desk)
565565
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
566566
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
567+
APOLLO_CLIENT_ID: z.string().optional(),
568+
APOLLO_CLIENT_SECRET: z.string().optional(),
567569
PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID
568570
PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret
569571
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID

0 commit comments

Comments
 (0)