reconnect option to connect diff account

This commit is contained in:
Vikhyath Mondreti
2026-02-13 12:12:56 -08:00
parent dcf40be189
commit fa32b9e687
7 changed files with 259 additions and 48 deletions

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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'}
</Button>
{selectedCredential.type === 'oauth' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Reconnect account</Tooltip.Content>
</Tooltip.Root>
)}
{selectedCredential.type === 'env_personal' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>

View File

@@ -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,

View File

@@ -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 })
}
}
}

View File

@@ -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;
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;

View File

@@ -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(),
},