Skip to content

Commit 5815d9f

Browse files
fix(credentials): autosync behaviour cross workspace (#3511)
* fix(credentials): autosync behaviour cross workspace * address comments
1 parent e6c511a commit 5815d9f

File tree

6 files changed

+162
-89
lines changed

6 files changed

+162
-89
lines changed

apps/sim/app/api/auth/oauth2/shopify/store/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { getBaseUrl } from '@/lib/core/utils/urls'
8+
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
89
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
910

1011
const logger = createLogger('ShopifyStore')
@@ -88,6 +89,28 @@ export async function GET(request: NextRequest) {
8889
)
8990
}
9091

92+
const persisted =
93+
existing ??
94+
(await db.query.account.findFirst({
95+
where: and(
96+
eq(account.userId, session.user.id),
97+
eq(account.providerId, 'shopify'),
98+
eq(account.accountId, stableAccountId)
99+
),
100+
}))
101+
102+
if (persisted) {
103+
try {
104+
await processCredentialDraft({
105+
userId: session.user.id,
106+
providerId: 'shopify',
107+
accountId: persisted.id,
108+
})
109+
} catch (error) {
110+
logger.error('Failed to process credential draft for Shopify', { error })
111+
}
112+
}
113+
91114
const returnUrl = request.cookies.get('shopify_return_url')?.value
92115

93116
const redirectUrl = returnUrl || `${baseUrl}/workspace`

apps/sim/app/api/auth/trello/store/route.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { db } from '@sim/db'
2+
import { account } from '@sim/db/schema'
13
import { createLogger } from '@sim/logger'
24
import { and, eq } from 'drizzle-orm'
35
import { type NextRequest, NextResponse } from 'next/server'
46
import { getSession } from '@/lib/auth'
57
import { env } from '@/lib/core/config/env'
8+
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
69
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
7-
import { db } from '@/../../packages/db'
8-
import { account } from '@/../../packages/db/schema'
910

1011
const logger = createLogger('TrelloStore')
1112

@@ -87,6 +88,28 @@ export async function POST(request: NextRequest) {
8788
)
8889
}
8990

91+
const persisted =
92+
existing ??
93+
(await db.query.account.findFirst({
94+
where: and(
95+
eq(account.userId, session.user.id),
96+
eq(account.providerId, 'trello'),
97+
eq(account.accountId, trelloUser.id)
98+
),
99+
}))
100+
101+
if (persisted) {
102+
try {
103+
await processCredentialDraft({
104+
userId: session.user.id,
105+
providerId: 'trello',
106+
accountId: persisted.id,
107+
})
108+
} catch (error) {
109+
logger.error('Failed to process credential draft for Trello', { error })
110+
}
111+
}
112+
90113
return NextResponse.json({ success: true })
91114
} catch (error) {
92115
logger.error('Error storing Trello token:', error)

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useMemo, useState } from 'react'
3+
import { useCallback, useMemo, useState } from 'react'
44
import { ArrowLeft, Loader2, Plus, Search } from 'lucide-react'
55
import { useParams } from 'next/navigation'
66
import {
@@ -18,6 +18,7 @@ import {
1818
ModalFooter,
1919
ModalHeader,
2020
} from '@/components/emcn'
21+
import { useSession } from '@/lib/auth/auth-client'
2122
import {
2223
getCanonicalScopesForProvider,
2324
getProviderIdFromServiceId,
@@ -59,6 +60,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
5960
const [searchTerm, setSearchTerm] = useState('')
6061

6162
const { workspaceId } = useParams<{ workspaceId: string }>()
63+
const { data: session } = useSession()
6264
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
6365

6466
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
@@ -131,6 +133,35 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
131133
)
132134
}
133135

136+
const handleConnectNewAccount = useCallback(async () => {
137+
if (!connectorConfig || !connectorProviderId || !workspaceId) return
138+
139+
const userName = session?.user?.name
140+
const integrationName = connectorConfig.name
141+
const displayName = userName ? `${userName}'s ${integrationName}` : integrationName
142+
143+
try {
144+
const res = await fetch('/api/credentials/draft', {
145+
method: 'POST',
146+
headers: { 'Content-Type': 'application/json' },
147+
body: JSON.stringify({
148+
workspaceId,
149+
providerId: connectorProviderId,
150+
displayName,
151+
}),
152+
})
153+
if (!res.ok) {
154+
setError('Failed to prepare credential. Please try again.')
155+
return
156+
}
157+
} catch {
158+
setError('Failed to prepare credential. Please try again.')
159+
return
160+
}
161+
162+
setShowOAuthModal(true)
163+
}, [connectorConfig, connectorProviderId, workspaceId, session?.user?.name])
164+
134165
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
135166

136167
const filteredEntries = useMemo(() => {
@@ -238,7 +269,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
238269
value: '__connect_new__',
239270
icon: Plus,
240271
onSelect: () => {
241-
setShowOAuthModal(true)
272+
void handleConnectNewAccount()
242273
},
243274
},
244275
]}

apps/sim/lib/auth/auth.ts

Lines changed: 6 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,7 @@ import {
6565
} from '@/lib/core/config/feature-flags'
6666
import { PlatformEvents } from '@/lib/core/telemetry'
6767
import { getBaseUrl } from '@/lib/core/utils/urls'
68-
import {
69-
handleCreateCredentialFromDraft,
70-
handleReconnectCredential,
71-
} from '@/lib/credentials/draft-hooks'
68+
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
7269
import { sendEmail } from '@/lib/messaging/email/mailer'
7370
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
7471
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -259,50 +256,12 @@ export const auth = betterAuth({
259256
})
260257
}
261258

262-
/**
263-
* If a pending credential draft exists for this (userId, providerId),
264-
* either create a new credential or reconnect an existing one.
265-
*
266-
* - draft.credentialId is null: create a new credential (normal connect flow)
267-
* - draft.credentialId is set: update existing credential's accountId (reconnect flow)
268-
*/
269259
try {
270-
const [draft] = await db
271-
.select()
272-
.from(schema.pendingCredentialDraft)
273-
.where(
274-
and(
275-
eq(schema.pendingCredentialDraft.userId, account.userId),
276-
eq(schema.pendingCredentialDraft.providerId, account.providerId),
277-
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
278-
)
279-
)
280-
.limit(1)
281-
282-
if (draft) {
283-
const now = new Date()
284-
285-
if (draft.credentialId) {
286-
await handleReconnectCredential({
287-
draft,
288-
newAccountId: account.id,
289-
workspaceId: draft.workspaceId,
290-
now,
291-
})
292-
} else {
293-
await handleCreateCredentialFromDraft({
294-
draft,
295-
accountId: account.id,
296-
providerId: account.providerId,
297-
userId: account.userId,
298-
now,
299-
})
300-
}
301-
302-
await db
303-
.delete(schema.pendingCredentialDraft)
304-
.where(eq(schema.pendingCredentialDraft.id, draft.id))
305-
}
260+
await processCredentialDraft({
261+
userId: account.userId,
262+
providerId: account.providerId,
263+
accountId: account.id,
264+
})
306265
} catch (error) {
307266
logger.error('[account.create.after] Failed to process credential draft', {
308267
userId: account.userId,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { db } from '@sim/db'
2+
import * as schema from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, sql } from 'drizzle-orm'
5+
import {
6+
handleCreateCredentialFromDraft,
7+
handleReconnectCredential,
8+
} from '@/lib/credentials/draft-hooks'
9+
10+
const logger = createLogger('CredentialDraftProcessor')
11+
12+
interface ProcessCredentialDraftParams {
13+
userId: string
14+
providerId: string
15+
accountId: string
16+
}
17+
18+
/**
19+
* Looks up a pending credential draft for the given user/provider and processes it.
20+
* Creates a new credential or reconnects an existing one depending on the draft state.
21+
* Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello).
22+
*/
23+
export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise<void> {
24+
const { userId, providerId, accountId } = params
25+
26+
const [draft] = await db
27+
.select()
28+
.from(schema.pendingCredentialDraft)
29+
.where(
30+
and(
31+
eq(schema.pendingCredentialDraft.userId, userId),
32+
eq(schema.pendingCredentialDraft.providerId, providerId),
33+
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
34+
)
35+
)
36+
.limit(1)
37+
38+
if (!draft) return
39+
40+
const now = new Date()
41+
42+
if (draft.credentialId) {
43+
await handleReconnectCredential({
44+
draft,
45+
newAccountId: accountId,
46+
workspaceId: draft.workspaceId,
47+
now,
48+
})
49+
} else {
50+
await handleCreateCredentialFromDraft({
51+
draft,
52+
accountId,
53+
providerId,
54+
userId,
55+
now,
56+
})
57+
}
58+
59+
await db
60+
.delete(schema.pendingCredentialDraft)
61+
.where(eq(schema.pendingCredentialDraft.id, draft.id))
62+
63+
logger.info('Processed credential draft', {
64+
draftId: draft.id,
65+
userId,
66+
providerId,
67+
isReconnect: Boolean(draft.credentialId),
68+
})
69+
}

apps/sim/lib/credentials/oauth.ts

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ interface SyncWorkspaceOAuthCredentialsForUserParams {
1212
}
1313

1414
interface SyncWorkspaceOAuthCredentialsForUserResult {
15-
createdCredentials: number
1615
updatedMemberships: number
1716
}
1817

@@ -23,7 +22,9 @@ function getPostgresErrorCode(error: unknown): string | undefined {
2322
}
2423

2524
/**
26-
* Ensures connected OAuth accounts for a user exist as workspace-scoped credentials.
25+
* Normalizes display names and ensures credential memberships for existing
26+
* workspace-scoped OAuth credentials. Does not create new credentials —
27+
* credential creation is handled by the draft-based OAuth connect flow.
2728
*/
2829
export async function syncWorkspaceOAuthCredentialsForUser(
2930
params: SyncWorkspaceOAuthCredentialsForUserParams
@@ -42,7 +43,7 @@ export async function syncWorkspaceOAuthCredentialsForUser(
4243
)
4344

4445
if (userAccounts.length === 0) {
45-
return { createdCredentials: 0, updatedMemberships: 0 }
46+
return { updatedMemberships: 0 }
4647
}
4748

4849
const accountIds = userAccounts.map((row) => row.id)
@@ -88,39 +89,6 @@ export async function syncWorkspaceOAuthCredentialsForUser(
8889
.where(eq(credential.id, existingCredential.id))
8990
}
9091

91-
const existingByAccountId = new Map(
92-
existingCredentials
93-
.filter((row) => Boolean(row.accountId))
94-
.map((row) => [row.accountId!, row.id])
95-
)
96-
97-
let createdCredentials = 0
98-
99-
for (const acc of userAccounts) {
100-
if (existingByAccountId.has(acc.id)) {
101-
continue
102-
}
103-
104-
try {
105-
await db.insert(credential).values({
106-
id: crypto.randomUUID(),
107-
workspaceId,
108-
type: 'oauth',
109-
displayName: getServiceConfigByProviderId(acc.providerId)?.name || acc.providerId,
110-
providerId: acc.providerId,
111-
accountId: acc.id,
112-
createdBy: userId,
113-
createdAt: now,
114-
updatedAt: now,
115-
})
116-
createdCredentials += 1
117-
} catch (error) {
118-
if (getPostgresErrorCode(error) !== '23505') {
119-
throw error
120-
}
121-
}
122-
}
123-
12492
const credentialRows = await db
12593
.select({ id: credential.id, accountId: credential.accountId })
12694
.from(credential)
@@ -137,7 +105,7 @@ export async function syncWorkspaceOAuthCredentialsForUser(
137105
)
138106
const allCredentialIds = Array.from(credentialIdByAccountId.values())
139107
if (allCredentialIds.length === 0) {
140-
return { createdCredentials, updatedMemberships: 0 }
108+
return { updatedMemberships: 0 }
141109
}
142110

143111
const existingMemberships = await db
@@ -196,5 +164,5 @@ export async function syncWorkspaceOAuthCredentialsForUser(
196164
}
197165
}
198166

199-
return { createdCredentials, updatedMemberships }
167+
return { updatedMemberships }
200168
}

0 commit comments

Comments
 (0)