diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts
index ca58a9a44..29addeea2 100644
--- a/apps/sim/app/api/credentials/draft/route.ts
+++ b/apps/sim/app/api/credentials/draft/route.ts
@@ -15,6 +15,7 @@ const createDraftSchema = z.object({
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
+ credentialId: z.string().min(1).optional(),
})
export async function POST(request: Request) {
@@ -30,7 +31,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
- const { workspaceId, providerId, displayName, description } = parsed.data
+ const { workspaceId, providerId, displayName, description, credentialId } = parsed.data
const userId = session.user.id
const now = new Date()
@@ -49,6 +50,7 @@ export async function POST(request: Request) {
providerId,
displayName,
description: description || null,
+ credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
@@ -61,12 +63,19 @@ export async function POST(request: Request) {
set: {
displayName,
description: description || null,
+ credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
- logger.info('Credential draft saved', { userId, workspaceId, providerId, displayName })
+ logger.info('Credential draft saved', {
+ userId,
+ workspaceId,
+ providerId,
+ displayName,
+ credentialId: credentialId || null,
+ })
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index b4fda44e1..a4f7648fa 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -268,11 +268,26 @@ const WorkflowContent = React.memo(() => {
providerId,
preCount,
workspaceId: wsId,
+ reconnect,
} = JSON.parse(pending) as {
displayName: string
providerId: string
preCount: number
workspaceId: string
+ reconnect?: boolean
+ }
+
+ if (reconnect) {
+ addNotification({
+ level: 'info',
+ message: `"${displayName}" reconnected successfully.`,
+ })
+ window.dispatchEvent(
+ new CustomEvent('oauth-credentials-updated', {
+ detail: { providerId, workspaceId: wsId },
+ })
+ )
+ return
}
const response = await fetch(
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx
index e37774a72..04a57eb84 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx
@@ -2,7 +2,7 @@
import { createElement, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { AlertTriangle, Plus, Search, Share2, Trash2 } from 'lucide-react'
+import { AlertTriangle, Plus, RefreshCw, Search, Share2, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -845,6 +845,52 @@ export function CredentialsManager() {
}
}
+ const handleReconnectOAuth = async () => {
+ if (
+ !selectedCredential ||
+ selectedCredential.type !== 'oauth' ||
+ !selectedCredential.providerId ||
+ !workspaceId
+ )
+ return
+
+ setDetailsError(null)
+
+ try {
+ await fetch('/api/credentials/draft', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ workspaceId,
+ providerId: selectedCredential.providerId,
+ displayName: selectedCredential.displayName,
+ description: selectedCredential.description || undefined,
+ credentialId: selectedCredential.id,
+ }),
+ })
+
+ window.sessionStorage.setItem(
+ 'sim.oauth-connect-pending',
+ JSON.stringify({
+ displayName: selectedCredential.displayName,
+ providerId: selectedCredential.providerId,
+ preCount: credentials.filter((c) => c.type === 'oauth').length,
+ workspaceId,
+ reconnect: true,
+ })
+ )
+
+ await connectOAuthService.mutateAsync({
+ providerId: selectedCredential.providerId,
+ callbackURL: window.location.href,
+ })
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : 'Failed to start reconnect'
+ setDetailsError(message)
+ logger.error('Failed to reconnect OAuth credential', error)
+ }
+ }
+
const handleAddMember = async () => {
if (!selectedCredential || !memberUserId) return
try {
@@ -983,6 +1029,20 @@ export function CredentialsManager() {
>
{isSavingDetails ? 'Saving...' : 'Save'}
+ {selectedCredential.type === 'oauth' && (
+
+
+
+
+ Reconnect account
+
+ )}
{selectedCredential.type === 'env_personal' && (
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index f54977890..726db4cec 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -55,6 +55,10 @@ 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 { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -244,8 +248,10 @@ export const auth = betterAuth({
/**
* If a pending credential draft exists for this (userId, providerId),
- * create the credential now with the user's chosen display name.
- * This is deterministic — the account row is guaranteed to exist.
+ * 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
@@ -261,52 +267,22 @@ export const auth = betterAuth({
.limit(1)
if (draft) {
- const credentialId = crypto.randomUUID()
const now = new Date()
- try {
- await db.insert(schema.credential).values({
- id: credentialId,
+ if (draft.credentialId) {
+ await handleReconnectCredential({
+ draft,
+ newAccountId: account.id,
workspaceId: draft.workspaceId,
- type: 'oauth',
- displayName: draft.displayName,
- description: draft.description ?? null,
- providerId: account.providerId,
- accountId: account.id,
- createdBy: account.userId,
- createdAt: now,
- updatedAt: now,
+ now,
})
-
- await db.insert(schema.credentialMember).values({
- id: crypto.randomUUID(),
- credentialId,
+ } else {
+ await handleCreateCredentialFromDraft({
+ draft,
+ accountId: account.id,
+ providerId: account.providerId,
userId: account.userId,
- role: 'admin',
- status: 'active',
- joinedAt: now,
- invitedBy: account.userId,
- createdAt: now,
- updatedAt: now,
- })
-
- logger.info('[account.create.after] Created credential from draft', {
- credentialId,
- displayName: draft.displayName,
- providerId: account.providerId,
- accountId: account.id,
- })
- } catch (insertError: unknown) {
- const code =
- insertError && typeof insertError === 'object' && 'code' in insertError
- ? (insertError as { code: string }).code
- : undefined
- if (code !== '23505') {
- throw insertError
- }
- logger.info('[account.create.after] Credential already exists, skipping draft', {
- providerId: account.providerId,
- accountId: account.id,
+ now,
})
}
@@ -315,7 +291,7 @@ export const auth = betterAuth({
.where(eq(schema.pendingCredentialDraft.id, draft.id))
}
} catch (error) {
- logger.error('[account.create.after] Failed to create credential from draft', {
+ logger.error('[account.create.after] Failed to process credential draft', {
userId: account.userId,
providerId: account.providerId,
error,
diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts
new file mode 100644
index 000000000..8f852210f
--- /dev/null
+++ b/apps/sim/lib/credentials/draft-hooks.ts
@@ -0,0 +1,148 @@
+import { db } from '@sim/db'
+import * as schema from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq, sql } from 'drizzle-orm'
+
+const logger = createLogger('CredentialDraftHooks')
+
+/**
+ * Creates a new credential from a pending draft (normal OAuth connect flow).
+ */
+export async function handleCreateCredentialFromDraft(params: {
+ draft: { workspaceId: string; displayName: string; description: string | null }
+ accountId: string
+ providerId: string
+ userId: string
+ now: Date
+}) {
+ const { draft, accountId, providerId, userId, now } = params
+ const credentialId = crypto.randomUUID()
+
+ try {
+ await db.insert(schema.credential).values({
+ id: credentialId,
+ workspaceId: draft.workspaceId,
+ type: 'oauth',
+ displayName: draft.displayName,
+ description: draft.description ?? null,
+ providerId,
+ accountId,
+ createdBy: userId,
+ createdAt: now,
+ updatedAt: now,
+ })
+
+ await db.insert(schema.credentialMember).values({
+ id: crypto.randomUUID(),
+ credentialId,
+ userId,
+ role: 'admin',
+ status: 'active',
+ joinedAt: now,
+ invitedBy: userId,
+ createdAt: now,
+ updatedAt: now,
+ })
+
+ logger.info('Created credential from draft', {
+ credentialId,
+ displayName: draft.displayName,
+ providerId,
+ accountId,
+ })
+ } catch (insertError: unknown) {
+ const code =
+ insertError && typeof insertError === 'object' && 'code' in insertError
+ ? (insertError as { code: string }).code
+ : undefined
+ if (code !== '23505') {
+ throw insertError
+ }
+ logger.info('Credential already exists, skipping draft', {
+ providerId,
+ accountId,
+ })
+ }
+}
+
+/**
+ * Reconnects an existing credential to a new OAuth account.
+ * Handles unique constraint checks and orphaned account cleanup.
+ */
+export async function handleReconnectCredential(params: {
+ draft: { credentialId: string | null; workspaceId: string; displayName: string }
+ newAccountId: string
+ workspaceId: string
+ now: Date
+}) {
+ const { draft, newAccountId, workspaceId, now } = params
+ if (!draft.credentialId) return
+
+ const [existingCredential] = await db
+ .select({ id: schema.credential.id, accountId: schema.credential.accountId })
+ .from(schema.credential)
+ .where(eq(schema.credential.id, draft.credentialId))
+ .limit(1)
+
+ if (!existingCredential) {
+ logger.warn('Credential not found for reconnect, skipping', {
+ credentialId: draft.credentialId,
+ })
+ return
+ }
+
+ const oldAccountId = existingCredential.accountId
+
+ if (oldAccountId === newAccountId) {
+ logger.info('Account unchanged during reconnect, skipping update', {
+ credentialId: draft.credentialId,
+ accountId: newAccountId,
+ })
+ return
+ }
+
+ const [conflicting] = await db
+ .select({ id: schema.credential.id })
+ .from(schema.credential)
+ .where(
+ and(
+ eq(schema.credential.workspaceId, workspaceId),
+ eq(schema.credential.accountId, newAccountId),
+ sql`${schema.credential.id} != ${draft.credentialId}`
+ )
+ )
+ .limit(1)
+
+ if (conflicting) {
+ logger.warn('New account already used by another credential, skipping reconnect', {
+ credentialId: draft.credentialId,
+ newAccountId,
+ conflictingCredentialId: conflicting.id,
+ })
+ return
+ }
+
+ await db
+ .update(schema.credential)
+ .set({ accountId: newAccountId, updatedAt: now })
+ .where(eq(schema.credential.id, draft.credentialId))
+
+ logger.info('Reconnected credential to new account', {
+ credentialId: draft.credentialId,
+ oldAccountId,
+ newAccountId,
+ })
+
+ if (oldAccountId) {
+ const [stillReferenced] = await db
+ .select({ id: schema.credential.id })
+ .from(schema.credential)
+ .where(eq(schema.credential.accountId, oldAccountId))
+ .limit(1)
+
+ if (!stillReferenced) {
+ await db.delete(schema.account).where(eq(schema.account.id, oldAccountId))
+ logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId })
+ }
+ }
+}
diff --git a/packages/db/migrations/0154_luxuriant_maria_hill.sql b/packages/db/migrations/0154_luxuriant_maria_hill.sql
index e4d1d952c..e005f3001 100644
--- a/packages/db/migrations/0154_luxuriant_maria_hill.sql
+++ b/packages/db/migrations/0154_luxuriant_maria_hill.sql
@@ -273,4 +273,6 @@ SELECT
FROM "credential" c
WHERE c.type = 'env_personal'::"credential_type"
AND c.env_owner_user_id IS NOT NULL
-ON CONFLICT DO NOTHING;
\ No newline at end of file
+ON CONFLICT DO NOTHING;
+--> statement-breakpoint
+ALTER TABLE "pending_credential_draft" ADD COLUMN IF NOT EXISTS "credential_id" text REFERENCES "credential"("id") ON DELETE CASCADE;
\ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index ef81ab84c..3d31ab8b1 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -2109,6 +2109,7 @@ export const pendingCredentialDraft = pgTable(
providerId: text('provider_id').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
+ credentialId: text('credential_id').references(() => credential.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},