From fa32b9e68721cf5e61e86efe5c28d9fc40be7e0a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 13 Feb 2026 12:12:56 -0800 Subject: [PATCH] reconnect option to connect diff account --- apps/sim/app/api/credentials/draft/route.ts | 13 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 15 ++ .../credentials/credentials-manager.tsx | 62 +++++++- apps/sim/lib/auth/auth.ts | 64 +++----- apps/sim/lib/credentials/draft-hooks.ts | 148 ++++++++++++++++++ .../migrations/0154_luxuriant_maria_hill.sql | 4 +- packages/db/schema.ts | 1 + 7 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 apps/sim/lib/credentials/draft-hooks.ts 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(), },