fix(credentials): autosync behaviour cross workspace (#3511)

* fix(credentials): autosync behaviour cross workspace

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-03-10 19:23:44 -07:00
committed by GitHub
parent e6c511a6f3
commit 5815d9f556
6 changed files with 162 additions and 89 deletions

View File

@@ -5,6 +5,7 @@ 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 { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('ShopifyStore')
@@ -88,6 +89,28 @@ export async function GET(request: NextRequest) {
)
}
const persisted =
existing ??
(await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
}))
if (persisted) {
try {
await processCredentialDraft({
userId: session.user.id,
providerId: 'shopify',
accountId: persisted.id,
})
} catch (error) {
logger.error('Failed to process credential draft for Shopify', { error })
}
}
const returnUrl = request.cookies.get('shopify_return_url')?.value
const redirectUrl = returnUrl || `${baseUrl}/workspace`

View File

@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
import { db } from '@/../../packages/db'
import { account } from '@/../../packages/db/schema'
const logger = createLogger('TrelloStore')
@@ -87,6 +88,28 @@ export async function POST(request: NextRequest) {
)
}
const persisted =
existing ??
(await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
}))
if (persisted) {
try {
await processCredentialDraft({
userId: session.user.id,
providerId: 'trello',
accountId: persisted.id,
})
} catch (error) {
logger.error('Failed to process credential draft for Trello', { error })
}
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error storing Trello token:', error)

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { ArrowLeft, Loader2, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
@@ -18,6 +18,7 @@ import {
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -59,6 +60,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const [searchTerm, setSearchTerm] = useState('')
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: session } = useSession()
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
@@ -131,6 +133,35 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
)
}
const handleConnectNewAccount = useCallback(async () => {
if (!connectorConfig || !connectorProviderId || !workspaceId) return
const userName = session?.user?.name
const integrationName = connectorConfig.name
const displayName = userName ? `${userName}'s ${integrationName}` : integrationName
try {
const res = await fetch('/api/credentials/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
providerId: connectorProviderId,
displayName,
}),
})
if (!res.ok) {
setError('Failed to prepare credential. Please try again.')
return
}
} catch {
setError('Failed to prepare credential. Please try again.')
return
}
setShowOAuthModal(true)
}, [connectorConfig, connectorProviderId, workspaceId, session?.user?.name])
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
const filteredEntries = useMemo(() => {
@@ -238,7 +269,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
value: '__connect_new__',
icon: Plus,
onSelect: () => {
setShowOAuthModal(true)
void handleConnectNewAccount()
},
},
]}

View File

@@ -65,10 +65,7 @@ import {
} from '@/lib/core/config/feature-flags'
import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
handleCreateCredentialFromDraft,
handleReconnectCredential,
} from '@/lib/credentials/draft-hooks'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -259,50 +256,12 @@ export const auth = betterAuth({
})
}
/**
* If a pending credential draft exists for this (userId, providerId),
* either create a new credential or reconnect an existing one.
*
* - draft.credentialId is null: create a new credential (normal connect flow)
* - draft.credentialId is set: update existing credential's accountId (reconnect flow)
*/
try {
const [draft] = await db
.select()
.from(schema.pendingCredentialDraft)
.where(
and(
eq(schema.pendingCredentialDraft.userId, account.userId),
eq(schema.pendingCredentialDraft.providerId, account.providerId),
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
)
)
.limit(1)
if (draft) {
const now = new Date()
if (draft.credentialId) {
await handleReconnectCredential({
draft,
newAccountId: account.id,
workspaceId: draft.workspaceId,
now,
})
} else {
await handleCreateCredentialFromDraft({
draft,
accountId: account.id,
providerId: account.providerId,
userId: account.userId,
now,
})
}
await db
.delete(schema.pendingCredentialDraft)
.where(eq(schema.pendingCredentialDraft.id, draft.id))
}
await processCredentialDraft({
userId: account.userId,
providerId: account.providerId,
accountId: account.id,
})
} catch (error) {
logger.error('[account.create.after] Failed to process credential draft', {
userId: account.userId,

View File

@@ -0,0 +1,69 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import {
handleCreateCredentialFromDraft,
handleReconnectCredential,
} from '@/lib/credentials/draft-hooks'
const logger = createLogger('CredentialDraftProcessor')
interface ProcessCredentialDraftParams {
userId: string
providerId: string
accountId: string
}
/**
* Looks up a pending credential draft for the given user/provider and processes it.
* Creates a new credential or reconnects an existing one depending on the draft state.
* Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello).
*/
export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise<void> {
const { userId, providerId, accountId } = params
const [draft] = await db
.select()
.from(schema.pendingCredentialDraft)
.where(
and(
eq(schema.pendingCredentialDraft.userId, userId),
eq(schema.pendingCredentialDraft.providerId, providerId),
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
)
)
.limit(1)
if (!draft) return
const now = new Date()
if (draft.credentialId) {
await handleReconnectCredential({
draft,
newAccountId: accountId,
workspaceId: draft.workspaceId,
now,
})
} else {
await handleCreateCredentialFromDraft({
draft,
accountId,
providerId,
userId,
now,
})
}
await db
.delete(schema.pendingCredentialDraft)
.where(eq(schema.pendingCredentialDraft.id, draft.id))
logger.info('Processed credential draft', {
draftId: draft.id,
userId,
providerId,
isReconnect: Boolean(draft.credentialId),
})
}

View File

@@ -12,7 +12,6 @@ interface SyncWorkspaceOAuthCredentialsForUserParams {
}
interface SyncWorkspaceOAuthCredentialsForUserResult {
createdCredentials: number
updatedMemberships: number
}
@@ -23,7 +22,9 @@ function getPostgresErrorCode(error: unknown): string | undefined {
}
/**
* Ensures connected OAuth accounts for a user exist as workspace-scoped credentials.
* Normalizes display names and ensures credential memberships for existing
* workspace-scoped OAuth credentials. Does not create new credentials —
* credential creation is handled by the draft-based OAuth connect flow.
*/
export async function syncWorkspaceOAuthCredentialsForUser(
params: SyncWorkspaceOAuthCredentialsForUserParams
@@ -42,7 +43,7 @@ export async function syncWorkspaceOAuthCredentialsForUser(
)
if (userAccounts.length === 0) {
return { createdCredentials: 0, updatedMemberships: 0 }
return { updatedMemberships: 0 }
}
const accountIds = userAccounts.map((row) => row.id)
@@ -88,39 +89,6 @@ export async function syncWorkspaceOAuthCredentialsForUser(
.where(eq(credential.id, existingCredential.id))
}
const existingByAccountId = new Map(
existingCredentials
.filter((row) => Boolean(row.accountId))
.map((row) => [row.accountId!, row.id])
)
let createdCredentials = 0
for (const acc of userAccounts) {
if (existingByAccountId.has(acc.id)) {
continue
}
try {
await db.insert(credential).values({
id: crypto.randomUUID(),
workspaceId,
type: 'oauth',
displayName: getServiceConfigByProviderId(acc.providerId)?.name || acc.providerId,
providerId: acc.providerId,
accountId: acc.id,
createdBy: userId,
createdAt: now,
updatedAt: now,
})
createdCredentials += 1
} catch (error) {
if (getPostgresErrorCode(error) !== '23505') {
throw error
}
}
}
const credentialRows = await db
.select({ id: credential.id, accountId: credential.accountId })
.from(credential)
@@ -137,7 +105,7 @@ export async function syncWorkspaceOAuthCredentialsForUser(
)
const allCredentialIds = Array.from(credentialIdByAccountId.values())
if (allCredentialIds.length === 0) {
return { createdCredentials, updatedMemberships: 0 }
return { updatedMemberships: 0 }
}
const existingMemberships = await db
@@ -196,5 +164,5 @@ export async function syncWorkspaceOAuthCredentialsForUser(
}
}
return { createdCredentials, updatedMemberships }
return { updatedMemberships }
}