Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
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
111 changes: 108 additions & 3 deletions apps/sim/app/api/auth/oauth/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { account, workflow } from '@sim/db/schema'
import { account, credentialSetMember, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'

Expand Down Expand Up @@ -105,10 +105,10 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
idToken: account.idToken,
scope: account.scope,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
// Always use the most recently updated credential for this provider
.orderBy(desc(account.updatedAt))
.limit(1)

Expand Down Expand Up @@ -335,3 +335,108 @@ export async function refreshTokenIfNeeded(
throw error
}
}

export interface CredentialSetCredential {
userId: string
credentialId: string
accessToken: string
providerId: string
}

export async function getCredentialsForCredentialSet(
credentialSetId: string,
providerId: string
): Promise<CredentialSetCredential[]> {
logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`)

const members = await db
.select({ userId: credentialSetMember.userId })
.from(credentialSetMember)
.where(
and(
eq(credentialSetMember.credentialSetId, credentialSetId),
eq(credentialSetMember.status, 'active')
)
)

logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`)

if (members.length === 0) {
logger.warn(`No active members found for credential set ${credentialSetId}`)
return []
}

const userIds = members.map((m) => m.userId)
logger.debug(`Member user IDs: ${userIds.join(', ')}`)

const credentials = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
})
.from(account)
.where(and(inArray(account.userId, userIds), eq(account.providerId, providerId)))

logger.info(
`Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members`
)

const results: CredentialSetCredential[] = []

for (const cred of credentials) {
const now = new Date()
const tokenExpiry = cred.accessTokenExpiresAt
const shouldRefresh =
!!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now))

let accessToken = cred.accessToken

if (shouldRefresh && cred.refreshToken) {
try {
const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken)

if (refreshResult) {
accessToken = refreshResult.accessToken

const updateData: Record<string, unknown> = {
accessToken: refreshResult.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000),
updatedAt: new Date(),
}

if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) {
updateData.refreshToken = refreshResult.refreshToken
}

await db.update(account).set(updateData).where(eq(account.id, cred.id))

logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`)
}
} catch (error) {
logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, {
error: error instanceof Error ? error.message : String(error),
})
continue
}
}

if (accessToken) {
results.push({
userId: cred.userId,
credentialId: cred.id,
accessToken,
providerId,
})
}
}

logger.info(
`Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}`
)

return results
}
Loading
Loading