mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-14 08:25:03 -05:00
reconnect option to connect diff account
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
148
apps/sim/lib/credentials/draft-hooks.ts
Normal file
148
apps/sim/lib/credentials/draft-hooks.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user