Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f5b4ab0
progress on cred sets
icecrasher321 Jan 2, 2026
152577c
Merge branch 'staging' into feat/multi-creds
icecrasher321 Jan 4, 2026
92ded33
Merge branch 'staging' into feat/multi-creds
icecrasher321 Jan 5, 2026
d908a28
fix credential set system
icecrasher321 Jan 6, 2026
1db3e33
return data to render credential set in block preview
icecrasher321 Jan 6, 2026
2d5cc9b
progress
icecrasher321 Jan 6, 2026
223d1e8
invite flow
icecrasher321 Jan 6, 2026
2c67d3d
simplify code
icecrasher321 Jan 6, 2026
faada90
fix ui
icecrasher321 Jan 6, 2026
71693c3
fix tests
icecrasher321 Jan 6, 2026
68fc864
fix types
icecrasher321 Jan 6, 2026
69b359d
Merge branch 'staging' into feat/multi-creds
icecrasher321 Jan 6, 2026
3f67a7c
fix
icecrasher321 Jan 7, 2026
a202bc4
Merge branch 'feat/multi-creds' of github.com:simstudioai/sim into fe…
icecrasher321 Jan 7, 2026
dbdd56d
fix icon for outlook
icecrasher321 Jan 7, 2026
677332c
fix cred set name not showing up for owner
icecrasher321 Jan 7, 2026
3feb636
fix rendering of credential set name
icecrasher321 Jan 7, 2026
fc88aff
fix outlook well known folder id resolution
icecrasher321 Jan 7, 2026
0f9338d
fix perms for creating cred set
icecrasher321 Jan 7, 2026
bc74cbe
Merge branch 'staging' into feat/multi-creds
icecrasher321 Jan 7, 2026
34f4d15
add to docs and simplify ui
icecrasher321 Jan 7, 2026
cd0a08b
Merge origin/staging into feat/multi-creds
icecrasher321 Jan 7, 2026
3f7cab4
consolidate webhook code better
icecrasher321 Jan 7, 2026
9d97918
fix tests
icecrasher321 Jan 7, 2026
0373899
fix credential collab logic issue
icecrasher321 Jan 7, 2026
78dcaf2
Merge branch 'staging' into feat/multi-creds
icecrasher321 Jan 8, 2026
888f789
fix ui
icecrasher321 Jan 8, 2026
0178dbc
fix lint
icecrasher321 Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/docs/content/docs/en/triggers/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
<Card title="RSS Feed" href="/triggers/rss">
Monitor RSS and Atom feeds for new content
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
Monitor team Gmail and Outlook inboxes
</Card>
</Cards>

## Quick Comparison
Expand All @@ -43,6 +46,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
| **RSS Feed** | New item published to feed |
| **Email Polling Groups** | New email received in team Gmail or Outlook inboxes |

> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.

Expand All @@ -66,3 +70,24 @@ If your workflow has multiple triggers, the highest priority trigger will be exe

**External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing.

## Email Polling Groups

Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes with a single trigger. Requires a Team or Enterprise plan.

**Creating a Polling Group** (Admin/Owner)

1. Go to **Settings → Email Polling**
2. Click **Create** and choose Gmail or Outlook
3. Enter a name for the group

**Inviting Members**

1. Click **Add Members** on your polling group
2. Enter email addresses (comma or newline separated, or drag & drop a CSV)
3. Click **Send Invites**

Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization.

**Using in a Workflow**

When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow.
8 changes: 6 additions & 2 deletions apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,15 @@ function SignupFormContent({
setEmail(emailParam)
}

const redirectParam = searchParams.get('redirect')
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
if (redirectParam) {
setRedirectUrl(redirectParam)

if (redirectParam.startsWith('/invite/')) {
if (
redirectParam.startsWith('/invite/') ||
redirectParam.startsWith('/credential-account/')
) {
setIsInviteFlow(true)
}
}
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/app/api/auth/oauth/disconnect/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/ut

describe('OAuth Disconnect API Route', () => {
const mockGetSession = vi.fn()
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
}
const mockDb = {
delete: vi.fn().mockReturnThis(),
where: vi.fn(),
select: vi.fn().mockReturnValue(mockSelectChain),
}
const mockLogger = createMockLogger()
const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({})

const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'

Expand All @@ -33,6 +40,13 @@ describe('OAuth Disconnect API Route', () => {

vi.doMock('@sim/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
credentialSetMember: {
id: 'id',
credentialSetId: 'credentialSetId',
userId: 'userId',
status: 'status',
},
credentialSet: { id: 'id', providerId: 'providerId' },
}))

vi.doMock('drizzle-orm', () => ({
Expand All @@ -45,6 +59,14 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))

vi.doMock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))

vi.doMock('@/lib/webhooks/utils.server', () => ({
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
}))
})

afterEach(() => {
Expand Down
46 changes: 45 additions & 1 deletion apps/sim/app/api/auth/oauth/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { account, credentialSet, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, like, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -74,6 +75,49 @@ export async function POST(request: NextRequest) {
)
}

// Sync webhooks for all credential sets the user is a member of
// This removes webhooks that were using the disconnected credential
const userMemberships = await db
.select({
id: credentialSetMember.id,
credentialSetId: credentialSetMember.credentialSetId,
providerId: credentialSet.providerId,
})
.from(credentialSetMember)
.innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
.where(
and(
eq(credentialSetMember.userId, session.user.id),
eq(credentialSetMember.status, 'active')
)
)

for (const membership of userMemberships) {
// Only sync if the credential set matches this provider
// Credential sets store OAuth provider IDs like 'google-email' or 'outlook'
const matchesProvider =
membership.providerId === provider ||
membership.providerId === providerId ||
membership.providerId?.startsWith(`${provider}-`)

if (matchesProvider) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info(`[${requestId}] Synced webhooks after credential disconnect`, {
credentialSetId: membership.credentialSetId,
provider,
})
} catch (error) {
// Log but don't fail the disconnect - credential is already removed
logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, {
credentialSetId: membership.credentialSetId,
provider,
error,
})
}
}
}

return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/auth/oauth/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()

expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Credential ID is required')
expect(data).toHaveProperty(
'error',
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
expect(mockLogger.warn).toHaveBeenCalled()
})

Expand Down
54 changes: 42 additions & 12 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('OAuthTokenAPI')

const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/

const tokenRequestSchema = z.object({
credentialId: z
.string({ required_error: 'Credential ID is required' })
.min(1, 'Credential ID is required'),
workflowId: z.string().min(1, 'Workflow ID is required').nullish(),
})
const tokenRequestSchema = z
.object({
credentialId: z.string().min(1).optional(),
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
'Either credentialId or (credentialAccountUserId + providerId) is required'
)

const tokenQuerySchema = z.object({
credentialId: z
Expand Down Expand Up @@ -58,9 +63,37 @@ export async function POST(request: NextRequest) {
)
}

const { credentialId, workflowId } = parseResult.data
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data

if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
credentialAccountUserId,
providerId,
})

try {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) {
return NextResponse.json(
{
error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
},
{ status: 404 }
)
}

return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get OAuth token'
logger.warn(`[${requestId}] OAuth token error: ${message}`)
return NextResponse.json({ error: message }, { status: 403 })
}
}

if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}

// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
Expand All @@ -70,15 +103,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

// Fetch the credential as the owner to enforce ownership scoping
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)

if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}

try {
// Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)

let instanceUrl: string | undefined
Expand Down Expand Up @@ -145,7 +176,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}

// Get the credential from the database
const credential = await getCredential(requestId, credentialId, auth.userId)

if (!credential) {
Expand Down
Loading