mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(credentials): autosync behaviour cross workspace (#3511)
* fix(credentials): autosync behaviour cross workspace * address comments
This commit is contained in:
committed by
GitHub
parent
e6c511a6f3
commit
5815d9f556
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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,
|
||||
|
||||
69
apps/sim/lib/credentials/draft-processor.ts
Normal file
69
apps/sim/lib/credentials/draft-processor.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user