mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(oauth): credentials sharing for workflows (#939)
* improvement(oauth): credential UX while sharing workflows * fix tests * address greptile comments * fix linear, jira, folder selectors * fix routes * fix linear * jira fix attempt * jira fix attempt * jira fixes * fix * fix * fix jira * fix selector disable behaviour * minor fixes * clear selectors correctly * fix project selector jira * fix gdrive * fix labels dropdown * fix webhook realtime collab * fix * fix webhooks persistence * fix folders route * fix lint * test webhook intermittent error * fix * fix display
This commit is contained in:
@@ -125,7 +125,7 @@ describe('OAuth Credentials API Route', () => {
|
||||
})
|
||||
expect(data.credentials[1]).toMatchObject({
|
||||
id: 'credential-2',
|
||||
provider: 'google-email',
|
||||
provider: 'google-default',
|
||||
isDefault: true,
|
||||
})
|
||||
})
|
||||
@@ -158,7 +158,7 @@ describe('OAuth Credentials API Route', () => {
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('Provider is required')
|
||||
expect(data.error).toBe('Provider or credentialId is required')
|
||||
expect(mockLogger.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthService } from '@/lib/oauth/oauth'
|
||||
import { parseProvider } from '@/lib/oauth/oauth'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { account, user } from '@/db/schema'
|
||||
import { account, user, workflow } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -25,36 +26,96 @@ export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
// Get query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const providerParam = searchParams.get('provider') as OAuthService | null
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
// Authenticate requester (supports session, API key, internal JWT)
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
const requesterUserId = authResult.userId
|
||||
|
||||
// Get the provider from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const provider = searchParams.get('provider') as OAuthService | null
|
||||
// Resolve effective user id: workflow owner if workflowId provided (with access check); else requester
|
||||
let effectiveUserId: string
|
||||
if (workflowId) {
|
||||
// Load workflow owner and workspace for access control
|
||||
const rows = await db
|
||||
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!provider) {
|
||||
logger.warn(`[${requestId}] Missing provider parameter`)
|
||||
return NextResponse.json({ error: 'Provider is required' }, { status: 400 })
|
||||
if (!rows.length) {
|
||||
logger.warn(`[${requestId}] Workflow not found for credentials request`, { workflowId })
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const wf = rows[0]
|
||||
|
||||
if (requesterUserId !== wf.userId) {
|
||||
if (!wf.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Forbidden - workflow has no workspace and requester is not owner`,
|
||||
{
|
||||
requesterUserId,
|
||||
}
|
||||
)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const perm = await getUserEntityPermissions(requesterUserId, 'workspace', wf.workspaceId)
|
||||
if (perm === null) {
|
||||
logger.warn(`[${requestId}] Forbidden credentials request - no workspace access`, {
|
||||
requesterUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
effectiveUserId = wf.userId
|
||||
} else {
|
||||
effectiveUserId = requesterUserId
|
||||
}
|
||||
|
||||
// Parse the provider to get base provider and feature type
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
if (!providerParam && !credentialId) {
|
||||
logger.warn(`[${requestId}] Missing provider parameter`)
|
||||
return NextResponse.json({ error: 'Provider or credentialId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get all accounts for this user and provider
|
||||
const accounts = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, session.user.id), eq(account.providerId, provider)))
|
||||
// Parse the provider to get base provider and feature type (if provider is present)
|
||||
const { baseProvider } = parseProvider(providerParam || 'google-default')
|
||||
|
||||
let accountsData
|
||||
|
||||
if (credentialId) {
|
||||
// Foreign-aware lookup for a specific credential by id
|
||||
// If workflowId is provided and requester has access (checked above), allow fetching by id only
|
||||
if (workflowId) {
|
||||
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
|
||||
} else {
|
||||
// Fallback: constrain to requester's own credentials when not in a workflow context
|
||||
accountsData = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
|
||||
}
|
||||
} else {
|
||||
// Fetch all credentials for provider and effective user
|
||||
accountsData = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
|
||||
}
|
||||
|
||||
// Transform accounts into credentials
|
||||
const credentials = await Promise.all(
|
||||
accounts.map(async (acc) => {
|
||||
accountsData.map(async (acc) => {
|
||||
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
|
||||
const [_, featureType = 'default'] = acc.providerId.split('-')
|
||||
|
||||
@@ -109,7 +170,7 @@ export async function GET(request: NextRequest) {
|
||||
return {
|
||||
id: acc.id,
|
||||
name: displayName,
|
||||
provider,
|
||||
provider: acc.providerId,
|
||||
lastUsed: acc.updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
}
|
||||
|
||||
@@ -78,8 +78,9 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(data).toHaveProperty('accessToken', 'fresh-token')
|
||||
|
||||
// Verify mocks were called correctly
|
||||
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId, undefined)
|
||||
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
|
||||
// POST no longer calls getUserId; token resolution uses credential owner.
|
||||
expect(mockGetUserId).not.toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalled()
|
||||
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -110,12 +111,9 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('accessToken', 'fresh-token')
|
||||
|
||||
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId, 'workflow-id')
|
||||
expect(mockGetCredential).toHaveBeenCalledWith(
|
||||
mockRequestId,
|
||||
'credential-id',
|
||||
'workflow-owner-id'
|
||||
)
|
||||
// POST no longer calls getUserId; still refreshes successfully
|
||||
expect(mockGetUserId).not.toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing credentialId', async () => {
|
||||
@@ -132,6 +130,7 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle authentication failure', async () => {
|
||||
// Authentication failure no longer applies to POST path; treat as refresh failure via missing owner
|
||||
mockGetUserId.mockResolvedValueOnce(undefined)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
@@ -143,8 +142,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'User not authenticated')
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle workflow not found', async () => {
|
||||
@@ -160,8 +159,9 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toHaveProperty('error', 'Workflow not found')
|
||||
// With owner-based resolution, missing workflowId no longer matters.
|
||||
// If credential not found via owner lookup, returns 404 accordingly
|
||||
expect([401, 404]).toContain(response.status)
|
||||
})
|
||||
|
||||
it('should handle credential not found', async () => {
|
||||
@@ -177,8 +177,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toHaveProperty('error', 'Credential not found')
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
@@ -266,8 +266,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'User not authenticated')
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle credential not found', async () => {
|
||||
@@ -283,8 +283,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toHaveProperty('error', 'Credential not found')
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle missing access token', async () => {
|
||||
@@ -305,9 +305,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'No access token available')
|
||||
expect(mockLogger.warn).toHaveBeenCalled()
|
||||
expect([400, 401]).toContain(response.status)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
@@ -330,8 +329,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'Failed to refresh access token')
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getCredential, getUserId, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,23 +29,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Determine the user ID based on the context
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: workflowId ? 'Workflow not found' : 'User not authenticated' },
|
||||
{ status: workflowId ? 404 : 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credential = await getCredential(requestId, credentialId, userId)
|
||||
|
||||
if (!credential) {
|
||||
// Resolve the credential owner directly by id. This lets API/UI/webhooks run under
|
||||
// whichever user owns the persisted credential, not necessarily the session user
|
||||
// or workflow owner.
|
||||
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.error(`[${requestId}] Credential not found: ${credentialId}`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
// Fetch the credential verifying it belongs to the resolved owner
|
||||
const credential = await getCredential(requestId, credentialId, credentialOwnerUserId)
|
||||
|
||||
try {
|
||||
// Refresh the token if needed
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const fileId = searchParams.get('fileId')
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
|
||||
if (!credentialId || !fileId) {
|
||||
logger.warn(`[${requestId}] Missing required parameters`)
|
||||
@@ -47,17 +48,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
// Credential ownership:
|
||||
// - If session user owns the credential: allow
|
||||
// - If not, allow read-only resolution when a workflowId is present (collaboration case)
|
||||
if (credential.userId !== session.user.id && !workflowId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const ownerUserId = credential.userId
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -40,16 +40,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db
|
||||
// Get the credential from the database. Prefer session-owned credential, but
|
||||
// if not found, resolve by credential ID to support collaborator-owned credentials.
|
||||
let credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
@@ -60,7 +64,7 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function GET(request: NextRequest) {
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -56,17 +57,19 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
// Allow collaborator read when workflowId present; otherwise require ownership
|
||||
const ownerUserId = credential.userId
|
||||
const requesterUserId = session.user.id
|
||||
if (ownerUserId !== requesterUserId && !workflowId) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
credentialUserId: ownerUserId,
|
||||
requestUserId: requesterUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function GET(request: Request) {
|
||||
const accessToken = url.searchParams.get('accessToken')
|
||||
const providedCloudId = url.searchParams.get('cloudId')
|
||||
const query = url.searchParams.get('query') || ''
|
||||
const projectId = url.searchParams.get('projectId') || ''
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
@@ -131,36 +132,70 @@ export async function GET(request: Request) {
|
||||
params.append('query', query)
|
||||
}
|
||||
|
||||
// Use the correct Jira Cloud OAuth endpoint structure
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
|
||||
let data: any
|
||||
|
||||
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Response status:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage = errorData.message || `Failed to fetch issue suggestions (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
|
||||
if (query) {
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
|
||||
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
logger.info('Response status:', response.status, response.statusText)
|
||||
if (!response.ok) {
|
||||
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage =
|
||||
errorData.message || `Failed to fetch issue suggestions (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
data = await response.json()
|
||||
} else if (projectId) {
|
||||
// When no query, list latest issues for the selected project using Search API
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
|
||||
searchParams.append('maxResults', '25')
|
||||
searchParams.append('fields', 'summary,key')
|
||||
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
|
||||
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
|
||||
const response = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira Search API error details:', errorData)
|
||||
errorMessage =
|
||||
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
|
||||
} catch (_e) {
|
||||
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
const searchData = await response.json()
|
||||
const issues = (searchData.issues || []).map((it: any) => ({
|
||||
key: it.key,
|
||||
summary: it.fields?.summary || it.key,
|
||||
}))
|
||||
data = { sections: [{ issues }], cloudId }
|
||||
} else {
|
||||
data = { sections: [], cloudId }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
cloudId, // Return the cloudId so it can be cached
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Project } from '@linear/sdk'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,15 +23,39 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userId = session?.user?.id || ''
|
||||
if (!userId) {
|
||||
const requesterUserId = session?.user?.id || ''
|
||||
if (!requesterUserId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
|
||||
// Look up credential owner
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.error('Credential not found for Linear API', { credential })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
// If requester does not own the credential, allow only if workflowId present (collab context)
|
||||
if (credentialOwnerUserId !== requesterUserId && !workflowId) {
|
||||
logger.warn('Unauthorized Linear credential access attempt without workflow context', {
|
||||
credentialOwnerUserId,
|
||||
requesterUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
credentialOwnerUserId,
|
||||
workflowId || 'linear'
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Team } from '@linear/sdk'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,15 +23,39 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userId = session?.user?.id || ''
|
||||
if (!userId) {
|
||||
const requesterUserId = session?.user?.id || ''
|
||||
if (!requesterUserId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
|
||||
// Look up credential to determine owner
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.error('Credential not found for Linear API', { credential })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
// If requester does not own the credential, allow only if workflowId present (collab context)
|
||||
if (credentialOwnerUserId !== requesterUserId && !workflowId) {
|
||||
logger.warn('Unauthorized Linear credential access attempt without workflow context', {
|
||||
credentialOwnerUserId,
|
||||
requesterUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
credentialOwnerUserId,
|
||||
workflowId || 'linear'
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -25,15 +28,25 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the userId either from the session or from the workflowId
|
||||
const userId = session?.user?.id || ''
|
||||
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const ownerUserId = creds[0].userId
|
||||
if (ownerUserId !== userId && !workflowId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
// Allow read-only resolution when a workflowId is present
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
ownerUserId,
|
||||
'TeamsChannelsAPI'
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -118,7 +121,7 @@ export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
const body = await request.json()
|
||||
|
||||
const { credential } = body
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -126,15 +129,18 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the userId either from the session or from the workflowId
|
||||
const userId = session?.user?.id || ''
|
||||
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, body.workflowId)
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const ownerUserId = creds[0].userId
|
||||
// Allow read-only resolution when a workflowId is present
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, ownerUserId, 'TeamsChatsAPI')
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,15 +23,24 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the userId either from the session or from the workflowId
|
||||
const userId = session?.user?.id || ''
|
||||
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
// Resolve credential owner
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const ownerUserId = creds[0].userId
|
||||
// If session doesn't own it and no workflow context, reject
|
||||
if (ownerUserId !== userId && !workflowId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
// Allow read-only resolution when a workflowId is present, even if session user isn't the owner
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, ownerUserId, 'TeamsTeamsAPI')
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,22 +29,30 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the userId from the session
|
||||
const userId = session?.user?.id || ''
|
||||
// Ensure we have a session for permission checks
|
||||
const sessionUserId = session?.user?.id || ''
|
||||
|
||||
if (!userId) {
|
||||
if (!sessionUserId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Resolve the credential owner to support collaborator-owned credentials
|
||||
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.warn('Credential not found', { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
userId,
|
||||
credentialOwnerUserId,
|
||||
crypto.randomUUID().slice(0, 8)
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId, userId })
|
||||
logger.error('Failed to get access token', { credentialId, userId: credentialOwnerUserId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
|
||||
@@ -29,29 +29,63 @@ export async function GET(request: NextRequest) {
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const blockId = searchParams.get('blockId')
|
||||
|
||||
if (workflowId && blockId) {
|
||||
// Collaborative-aware path: allow collaborators with read access to view webhooks
|
||||
// Fetch workflow to verify access
|
||||
const wf = await db
|
||||
.select({ id: workflow.id, userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf.length) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const wfRecord = wf[0]
|
||||
let canRead = wfRecord.userId === session.user.id
|
||||
if (!canRead && wfRecord.workspaceId) {
|
||||
const permission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
wfRecord.workspaceId
|
||||
)
|
||||
canRead = permission === 'read' || permission === 'write' || permission === 'admin'
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ webhooks: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
const webhooks = await db
|
||||
.select({
|
||||
webhook: webhook,
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
},
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}`
|
||||
)
|
||||
return NextResponse.json({ webhooks }, { status: 200 })
|
||||
}
|
||||
|
||||
if (workflowId && !blockId) {
|
||||
// For now, allow the call but return empty results to avoid breaking the UI
|
||||
return NextResponse.json({ webhooks: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Fetching webhooks for user ${session.user.id}`, {
|
||||
filteredByWorkflow: !!workflowId,
|
||||
filteredByBlock: !!blockId,
|
||||
})
|
||||
|
||||
// Create where condition
|
||||
const conditions = [eq(workflow.userId, session.user.id)]
|
||||
|
||||
if (workflowId) {
|
||||
conditions.push(eq(webhook.workflowId, workflowId))
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
conditions.push(eq(webhook.blockId, blockId))
|
||||
}
|
||||
|
||||
const whereCondition = conditions.length > 1 ? and(...conditions) : conditions[0]
|
||||
|
||||
// Default: list webhooks owned by the session user
|
||||
logger.debug(`[${requestId}] Fetching user-owned webhooks for ${session.user.id}`)
|
||||
const webhooks = await db
|
||||
.select({
|
||||
webhook: webhook,
|
||||
@@ -62,9 +96,9 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(whereCondition)
|
||||
.where(eq(workflow.userId, session.user.id))
|
||||
|
||||
logger.info(`[${requestId}] Retrieved ${webhooks.length} webhooks for user ${session.user.id}`)
|
||||
logger.info(`[${requestId}] Retrieved ${webhooks.length} user-owned webhooks`)
|
||||
return NextResponse.json({ webhooks }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching webhooks`, error)
|
||||
@@ -95,17 +129,36 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// For credential-based providers (those that use polling instead of webhooks),
|
||||
// generate a dummy path if none provided since they don't use actual webhook URLs
|
||||
// but still need database entries for the polling services to find them
|
||||
// Determine final path with special handling for credential-based providers
|
||||
// to avoid generating a new path on every save.
|
||||
let finalPath = path
|
||||
if (!path || path.trim() === '') {
|
||||
// List of providers that use credential-based polling instead of webhooks
|
||||
const credentialBasedProviders = ['gmail', 'outlook']
|
||||
const credentialBasedProviders = ['gmail', 'outlook']
|
||||
const isCredentialBased = credentialBasedProviders.includes(provider)
|
||||
|
||||
if (credentialBasedProviders.includes(provider)) {
|
||||
finalPath = `${provider}-${crypto.randomUUID()}`
|
||||
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
|
||||
// If path is missing
|
||||
if (!finalPath || finalPath.trim() === '') {
|
||||
if (isCredentialBased) {
|
||||
// Try to reuse existing path for this workflow+block if one exists
|
||||
if (blockId) {
|
||||
const existingForBlock = await db
|
||||
.select({ id: webhook.id, path: webhook.path })
|
||||
.from(webhook)
|
||||
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
|
||||
.limit(1)
|
||||
|
||||
if (existingForBlock.length > 0) {
|
||||
finalPath = existingForBlock[0].path
|
||||
logger.info(
|
||||
`[${requestId}] Reusing existing dummy path for ${provider} trigger: ${finalPath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If still no path, generate a new dummy path (first-time save)
|
||||
if (!finalPath || finalPath.trim() === '') {
|
||||
finalPath = `${provider}-${crypto.randomUUID()}`
|
||||
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Missing path for webhook creation`, {
|
||||
hasWorkflowId: !!workflowId,
|
||||
@@ -160,29 +213,43 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if a webhook with the same path already exists
|
||||
const existingWebhooks = await db
|
||||
.select({ id: webhook.id, workflowId: webhook.workflowId })
|
||||
.from(webhook)
|
||||
.where(eq(webhook.path, finalPath))
|
||||
.limit(1)
|
||||
// Determine existing webhook to update (prefer by workflow+block for credential-based providers)
|
||||
let targetWebhookId: string | null = null
|
||||
if (isCredentialBased && blockId) {
|
||||
const existingForBlock = await db
|
||||
.select({ id: webhook.id })
|
||||
.from(webhook)
|
||||
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
|
||||
.limit(1)
|
||||
if (existingForBlock.length > 0) {
|
||||
targetWebhookId = existingForBlock[0].id
|
||||
}
|
||||
}
|
||||
if (!targetWebhookId) {
|
||||
const existingByPath = await db
|
||||
.select({ id: webhook.id, workflowId: webhook.workflowId })
|
||||
.from(webhook)
|
||||
.where(eq(webhook.path, finalPath))
|
||||
.limit(1)
|
||||
if (existingByPath.length > 0) {
|
||||
// If a webhook with the same path exists but belongs to a different workflow, return an error
|
||||
if (existingByPath[0].workflowId !== workflowId) {
|
||||
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
targetWebhookId = existingByPath[0].id
|
||||
}
|
||||
}
|
||||
|
||||
let savedWebhook: any = null // Variable to hold the result of save/update
|
||||
|
||||
// If a webhook with the same path exists but belongs to a different workflow, return an error
|
||||
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId !== workflowId) {
|
||||
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
|
||||
const finalProviderConfig = providerConfig
|
||||
|
||||
// If a webhook with the same path and workflowId exists, update it
|
||||
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId === workflowId) {
|
||||
if (targetWebhookId) {
|
||||
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`)
|
||||
const updatedResult = await db
|
||||
.update(webhook)
|
||||
@@ -193,7 +260,7 @@ export async function POST(request: NextRequest) {
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, existingWebhooks[0].id))
|
||||
.where(eq(webhook.id, targetWebhookId))
|
||||
.returning()
|
||||
savedWebhook = updatedResult[0]
|
||||
} else {
|
||||
@@ -262,7 +329,8 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||
try {
|
||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
|
||||
const success = await configureGmailPolling(userId, savedWebhook, requestId)
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
|
||||
|
||||
if (!success) {
|
||||
logger.error(`[${requestId}] Failed to configure Gmail polling`)
|
||||
@@ -296,7 +364,12 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
try {
|
||||
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
|
||||
const success = await configureOutlookPolling(userId, savedWebhook, requestId)
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
const success = await configureOutlookPolling(
|
||||
workflowRecord.userId,
|
||||
savedWebhook,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
logger.error(`[${requestId}] Failed to configure Outlook polling`)
|
||||
@@ -323,7 +396,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
// --- End Outlook specific logic ---
|
||||
|
||||
const status = existingWebhooks.length > 0 ? 200 : 201
|
||||
const status = targetWebhookId ? 200 : 201
|
||||
return NextResponse.json({ webhook: savedWebhook }, { status })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error creating/updating webhook`, {
|
||||
|
||||
+86
-32
@@ -24,6 +24,9 @@ import {
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
|
||||
@@ -47,6 +50,9 @@ export function CredentialSelector({
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [hasForeignMeta, setHasForeignMeta] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
|
||||
// Use collaborative state management via useSubBlockValue hook
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
@@ -81,45 +87,42 @@ export function CredentialSelector({
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
const creds = data.credentials as Credential[]
|
||||
let foreignMetaFound = false
|
||||
|
||||
// If we have a value but it's not in the credentials, reset it
|
||||
if (selectedId && !data.credentials.some((cred: Credential) => cred.id === selectedId)) {
|
||||
setSelectedId('')
|
||||
if (!isPreview) {
|
||||
setStoreValue('')
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select logic:
|
||||
// 1. If we already have a valid selection, keep it
|
||||
// 2. If there's a default credential, select it
|
||||
// 3. If there's only one credential, select it
|
||||
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
|
||||
if (
|
||||
(!selectedId || !data.credentials.some((cred: Credential) => cred.id === selectedId)) &&
|
||||
data.credentials.length > 0
|
||||
selectedId &&
|
||||
!(creds || []).some((cred: Credential) => cred.id === selectedId) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedId(defaultCred.id)
|
||||
if (!isPreview) {
|
||||
setStoreValue(defaultCred.id)
|
||||
}
|
||||
} else if (data.credentials.length === 1) {
|
||||
// If only one credential, select it
|
||||
setSelectedId(data.credentials[0].id)
|
||||
if (!isPreview) {
|
||||
setStoreValue(data.credentials[0].id)
|
||||
try {
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
if (meta.credentials?.length) {
|
||||
// Mark as foreign, but do NOT merge into list to avoid leaking owner email
|
||||
foreignMetaFound = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore meta errors
|
||||
}
|
||||
}
|
||||
|
||||
setHasForeignMeta(foreignMetaFound)
|
||||
setCredentials(creds)
|
||||
|
||||
// Do not auto-select or reset. We only show what's persisted.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [effectiveProviderId, selectedId, isPreview, setStoreValue])
|
||||
}, [effectiveProviderId, selectedId, activeWorkflowId])
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
@@ -128,6 +131,38 @@ export function CredentialSelector({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
if (!selectedId) {
|
||||
setHasForeignMeta(false)
|
||||
return
|
||||
}
|
||||
// If the selected credential exists in viewer's list, it's not foreign
|
||||
if ((credentials || []).some((cred) => cred.id === selectedId)) {
|
||||
setHasForeignMeta(false)
|
||||
return
|
||||
}
|
||||
if (!activeWorkflowId) return
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (aborted) return
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
setHasForeignMeta(!!meta.credentials?.length)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [selectedId, credentials, activeWorkflowId])
|
||||
|
||||
// This effect is no longer needed since we're using effectiveValue directly
|
||||
|
||||
// Listen for visibility changes to update credentials when user returns from settings
|
||||
@@ -156,12 +191,25 @@ export function CredentialSelector({
|
||||
|
||||
// Get the selected credential
|
||||
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
|
||||
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (credentialId: string) => {
|
||||
const previousId = selectedId || (effectiveValue as string) || ''
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
setStoreValue(credentialId)
|
||||
// If credential changed, clear other sub-block fields for a clean state
|
||||
if (previousId && previousId !== credentialId) {
|
||||
const wfId = (activeWorkflowId as string) || ''
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
Object.keys(blockValues).forEach((key) => {
|
||||
if (key !== subBlock.id) {
|
||||
collaborativeSetSubblockValue(blockId, key, '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -214,11 +262,17 @@ export function CredentialSelector({
|
||||
>
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
{getProviderIcon(provider)}
|
||||
{selectedCredential ? (
|
||||
<span className='truncate font-normal'>{selectedCredential.name}</span>
|
||||
) : (
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
|
||||
+3
-20
@@ -45,6 +45,7 @@ interface ConfluenceFileSelectorProps {
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
|
||||
credentialId?: string
|
||||
}
|
||||
|
||||
export function ConfluenceFileSelector({
|
||||
@@ -58,11 +59,12 @@ export function ConfluenceFileSelector({
|
||||
domain,
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
credentialId,
|
||||
}: ConfluenceFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -120,25 +122,6 @@ export function ConfluenceFileSelector({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
|
||||
+5
@@ -35,6 +35,7 @@ interface GoogleCalendarSelectorProps {
|
||||
showPreview?: boolean
|
||||
onCalendarInfoChange?: (info: GoogleCalendarInfo | null) => void
|
||||
credentialId: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function GoogleCalendarSelector({
|
||||
@@ -45,6 +46,7 @@ export function GoogleCalendarSelector({
|
||||
showPreview = true,
|
||||
onCalendarInfoChange,
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleCalendarSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [calendars, setCalendars] = useState<GoogleCalendarInfo[]>([])
|
||||
@@ -62,6 +64,9 @@ export function GoogleCalendarSelector({
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: credentialId,
|
||||
})
|
||||
if (workflowId) {
|
||||
queryParams.set('workflowId', workflowId)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tools/google_calendar/calendars?${queryParams.toString()}`)
|
||||
|
||||
|
||||
+18
-24
@@ -54,6 +54,8 @@ interface GoogleDrivePickerProps {
|
||||
onFileInfoChange?: (fileInfo: FileInfo | null) => void
|
||||
clientId: string
|
||||
apiKey: string
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function GoogleDrivePicker({
|
||||
@@ -69,6 +71,8 @@ export function GoogleDrivePicker({
|
||||
onFileInfoChange,
|
||||
clientId,
|
||||
apiKey,
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleDrivePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
@@ -105,25 +109,7 @@ export function GoogleDrivePicker({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do not auto-select. Respect persisted credential via prop when provided.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
@@ -133,6 +119,13 @@ export function GoogleDrivePicker({
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Prefer persisted credentialId if provided
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Fetch a single file by ID when we have a selectedFileId but no metadata
|
||||
const fetchFileById = useCallback(
|
||||
async (fileId: string) => {
|
||||
@@ -145,6 +138,7 @@ export function GoogleDrivePicker({
|
||||
credentialId: selectedCredentialId,
|
||||
fileId: fileId,
|
||||
})
|
||||
if (workflowId) queryParams.set('workflowId', workflowId)
|
||||
|
||||
const response = await fetch(`/api/tools/drive/file?${queryParams.toString()}`)
|
||||
|
||||
@@ -251,7 +245,10 @@ export function GoogleDrivePicker({
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/auth/oauth/token?credentialId=${selectedCredentialId}`)
|
||||
const url = new URL('/api/auth/oauth/token', window.location.origin)
|
||||
url.searchParams.set('credentialId', selectedCredentialId)
|
||||
// include workflowId if available via global registry (server adds session owner otherwise)
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch access token: ${response.status}`)
|
||||
@@ -500,10 +497,7 @@ export function GoogleDrivePicker({
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Ready to select files.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Click the button below to open the file picker.
|
||||
</p>
|
||||
<p className='font-medium text-sm'>No documents available.</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
+34
-26
@@ -46,6 +46,8 @@ interface JiraIssueSelectorProps {
|
||||
showPreview?: boolean
|
||||
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
|
||||
projectId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function JiraIssueSelector({
|
||||
@@ -60,11 +62,12 @@ export function JiraIssueSelector({
|
||||
showPreview = true,
|
||||
onIssueInfoChange,
|
||||
projectId,
|
||||
credentialId,
|
||||
}: JiraIssueSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedIssueId, setSelectedIssueId] = useState(value)
|
||||
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -73,6 +76,15 @@ export function JiraIssueSelector({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Keep local credential state in sync with persisted credentialId prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
} else if (!credentialId && selectedCredentialId) {
|
||||
setSelectedCredentialId('')
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@@ -124,25 +136,6 @@ export function JiraIssueSelector({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
@@ -242,6 +235,11 @@ export function JiraIssueSelector({
|
||||
const fetchIssues = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
// If no search query is provided, require a projectId before fetching
|
||||
if (!searchQuery && !projectId) {
|
||||
setIssues([])
|
||||
return
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
@@ -370,13 +368,12 @@ export function JiraIssueSelector({
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
// Fetch credentials when the dropdown opens (avoid fetching on mount with no credential)
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
if (open) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [open, fetchCredentials])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
@@ -384,7 +381,10 @@ export function JiraIssueSelector({
|
||||
|
||||
// Only fetch recent/default issues when opening the dropdown
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchIssues('') // Pass empty string to get recent or default issues
|
||||
// Only fetch on open when a project is selected; otherwise wait for user search
|
||||
if (projectId) {
|
||||
fetchIssues('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +406,14 @@ export function JiraIssueSelector({
|
||||
if (value !== selectedIssueId) {
|
||||
setSelectedIssueId(value)
|
||||
}
|
||||
// When the upstream value is cleared (e.g., project changed or remote user cleared),
|
||||
// clear local selection and preview immediately
|
||||
if (!value) {
|
||||
setSelectedIssue(null)
|
||||
setIssues([])
|
||||
setError(null)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Handle issue selection
|
||||
@@ -443,7 +451,7 @@ export function JiraIssueSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain}
|
||||
disabled={disabled || !domain || !selectedCredentialId}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedIssue ? (
|
||||
|
||||
+15
-24
@@ -60,6 +60,7 @@ export function TeamsMessageSelector({
|
||||
serviceId,
|
||||
showPreview = true,
|
||||
onMessageInfoChange,
|
||||
credential,
|
||||
selectionType = 'team',
|
||||
initialTeamId,
|
||||
workflowId,
|
||||
@@ -69,7 +70,7 @@ export function TeamsMessageSelector({
|
||||
const [teams, setTeams] = useState<TeamsMessageInfo[]>([])
|
||||
const [channels, setChannels] = useState<TeamsMessageInfo[]>([])
|
||||
const [chats, setChats] = useState<TeamsMessageInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credential || '')
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [selectedChatId, setSelectedChatId] = useState<string>('')
|
||||
@@ -102,25 +103,6 @@ export function TeamsMessageSelector({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
@@ -144,6 +126,7 @@ export function TeamsMessageSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -205,6 +188,7 @@ export function TeamsMessageSelector({
|
||||
body: JSON.stringify({
|
||||
credential: selectedCredentialId,
|
||||
teamId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -341,7 +325,6 @@ export function TeamsMessageSelector({
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch data when opening the dropdown
|
||||
if (isOpen && selectedCredentialId) {
|
||||
if (selectionStage === 'team') {
|
||||
@@ -671,9 +654,16 @@ export function TeamsMessageSelector({
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Restore selection based on selectionType and value
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && !selectedMessage) {
|
||||
if (credential && credential !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credential)
|
||||
}
|
||||
}, [credential, selectedCredentialId])
|
||||
|
||||
// Restore selection whenever the canonical value changes
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId) {
|
||||
if (selectionType === 'team') {
|
||||
restoreTeamSelection(value)
|
||||
} else if (selectionType === 'chat') {
|
||||
@@ -681,11 +671,12 @@ export function TeamsMessageSelector({
|
||||
} else if (selectionType === 'channel') {
|
||||
restoreChannelSelection(value)
|
||||
}
|
||||
} else {
|
||||
setSelectedMessage(null)
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
selectedMessage,
|
||||
selectionType,
|
||||
restoreTeamSelection,
|
||||
restoreChatSelection,
|
||||
|
||||
+10
-18
@@ -43,6 +43,7 @@ interface WealthboxFileSelectorProps {
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (itemInfo: WealthboxItemInfo | null) => void
|
||||
itemType?: 'contact'
|
||||
credentialId?: string
|
||||
}
|
||||
|
||||
export function WealthboxFileSelector({
|
||||
@@ -56,10 +57,11 @@ export function WealthboxFileSelector({
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
itemType = 'contact',
|
||||
credentialId,
|
||||
}: WealthboxFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedItemId, setSelectedItemId] = useState(value)
|
||||
const [selectedItem, setSelectedItem] = useState<WealthboxItemInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -94,23 +96,6 @@ export function WealthboxFileSelector({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', { error })
|
||||
@@ -120,6 +105,13 @@ export function WealthboxFileSelector({
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Debounced search function
|
||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
|
||||
+151
-55
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import {
|
||||
@@ -45,6 +46,8 @@ export function FileSelectorInput({
|
||||
const { getValue } = useSubBlockStore()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
@@ -61,6 +64,36 @@ export function FileSelectorInput({
|
||||
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
|
||||
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
|
||||
|
||||
// Determine if the persisted credential belongs to the current viewer
|
||||
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
const cred = (getValue(blockId, 'credential') as string) || ''
|
||||
if (!cred) {
|
||||
setIsForeignCredential(false)
|
||||
return
|
||||
}
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`)
|
||||
if (aborted) return
|
||||
if (!resp.ok) {
|
||||
setIsForeignCredential(true)
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
// If credential not returned for this session user, it's foreign
|
||||
setIsForeignCredential(!(data.credentials && data.credentials.length === 1))
|
||||
} catch {
|
||||
setIsForeignCredential(true)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockId, getValue(blockId, 'credential')])
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'google-drive'
|
||||
const isConfluence = provider === 'confluence'
|
||||
@@ -76,6 +109,7 @@ export function FileSelectorInput({
|
||||
const isMicrosoftPlanner = provider === 'microsoft-planner'
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : ''
|
||||
const jiraCredential = isJira ? (getValue(blockId, 'credential') as string) || '' : ''
|
||||
// For Discord, we need the bot token and server ID
|
||||
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
|
||||
const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : ''
|
||||
@@ -83,59 +117,53 @@ export function FileSelectorInput({
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
// Keep local selection in sync with store value (and preview)
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
const value = previewValue
|
||||
if (value && typeof value === 'string') {
|
||||
if (isJira) {
|
||||
setSelectedIssueId(value)
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId(value)
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId(value)
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId(value)
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId(value)
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId(value)
|
||||
} else {
|
||||
setSelectedFileId(value)
|
||||
}
|
||||
const effective = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
if (typeof effective === 'string' && effective !== '') {
|
||||
if (isJira) {
|
||||
setSelectedIssueId(effective)
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId(effective)
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId(effective)
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId(effective)
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId(effective)
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId(effective)
|
||||
} else {
|
||||
setSelectedFileId(effective)
|
||||
}
|
||||
} else {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
if (isJira) {
|
||||
setSelectedIssueId(value)
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId(value)
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId(value)
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId(value)
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId(value)
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId(value)
|
||||
} else {
|
||||
setSelectedFileId(value)
|
||||
}
|
||||
// Clear when value becomes empty
|
||||
if (isJira) {
|
||||
setSelectedIssueId('')
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId('')
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId('')
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId('')
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId('')
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId('')
|
||||
} else {
|
||||
setSelectedFileId('')
|
||||
}
|
||||
}
|
||||
}, [
|
||||
blockId,
|
||||
subBlock.id,
|
||||
getValue,
|
||||
isPreview,
|
||||
previewValue,
|
||||
storeValue,
|
||||
isJira,
|
||||
isDiscord,
|
||||
isMicrosoftTeams,
|
||||
isGoogleCalendar,
|
||||
isWealthbox,
|
||||
isMicrosoftSharePoint,
|
||||
isPreview,
|
||||
previewValue,
|
||||
])
|
||||
|
||||
// Handle file selection
|
||||
@@ -155,6 +183,10 @@ export function FileSelectorInput({
|
||||
if (isJira) {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
if (!issueKey) {
|
||||
// Also clear the manual issue key when cleared
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,13 +225,22 @@ export function FileSelectorInput({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<GoogleCalendarSelector
|
||||
value={selectedCalendarId}
|
||||
onChange={handleCalendarChange}
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedCalendarId(val)
|
||||
setCalendarInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
label={subBlock.placeholder || 'Select Google Calendar'}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onCalendarInfoChange={setCalendarInfo}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -243,14 +284,23 @@ export function FileSelectorInput({
|
||||
|
||||
// Render the appropriate picker based on provider
|
||||
if (isConfluence) {
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<ConfluenceFileSelector
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='confluence'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
@@ -259,6 +309,7 @@ export function FileSelectorInput({
|
||||
disabled={disabled || !domain}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
|
||||
credentialId={credential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -273,30 +324,52 @@ export function FileSelectorInput({
|
||||
}
|
||||
|
||||
if (isJira) {
|
||||
const credential = jiraCredential
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<JiraIssueSelector
|
||||
value={selectedIssueId}
|
||||
onChange={handleIssueChange}
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedIssueId(val)
|
||||
setIssueInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={disabled || !domain}
|
||||
disabled={
|
||||
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
|
||||
}
|
||||
showPreview={true}
|
||||
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
|
||||
credentialId={credential}
|
||||
projectId={(getValue(blockId, 'projectId') as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain && (
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
) : !credential ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Jira credentials first</p>
|
||||
</TooltipContent>
|
||||
) : !(getValue(blockId, 'projectId') as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira project first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -502,7 +575,11 @@ export function FileSelectorInput({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<TeamsMessageSelector
|
||||
value={selectedMessageId}
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(value, info) => {
|
||||
setSelectedMessageId(value)
|
||||
setMessageInfo(info || null)
|
||||
@@ -547,8 +624,16 @@ export function FileSelectorInput({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<WealthboxFileSelector
|
||||
value={selectedWealthboxItemId}
|
||||
onChange={handleWealthboxItemChange}
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedWealthboxItemId(val)
|
||||
setWealthboxItemInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='wealthbox'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
@@ -556,6 +641,7 @@ export function FileSelectorInput({
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setWealthboxItemInfo}
|
||||
credentialId={credential}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</div>
|
||||
@@ -576,8 +662,16 @@ export function FileSelectorInput({
|
||||
// Default to Google Drive picker
|
||||
return (
|
||||
<GoogleDrivePicker
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
value={
|
||||
(isPreview && previewValue !== undefined
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select file'}
|
||||
@@ -588,6 +682,8 @@ export function FileSelectorInput({
|
||||
onFileInfoChange={setFileInfo}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={(getValue(blockId, 'credential') as string) || ''}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+18
-15
@@ -5,8 +5,9 @@ import {
|
||||
type FolderInfo,
|
||||
FolderSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
|
||||
interface FolderSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -23,7 +24,8 @@ export function FolderSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: FolderSelectorInputProps) {
|
||||
const { getValue, setValue } = useSubBlockStore()
|
||||
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
|
||||
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
|
||||
|
||||
@@ -31,26 +33,27 @@ export function FolderSelectorInput({
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue)
|
||||
} else {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedFolderId(value)
|
||||
} else {
|
||||
const defaultValue = 'INBOX'
|
||||
setSelectedFolderId(defaultValue)
|
||||
if (!isPreview) {
|
||||
setValue(blockId, subBlock.id, defaultValue)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [blockId, subBlock.id, getValue, setValue, isPreview, previewValue])
|
||||
const current = storeValue as string | undefined
|
||||
if (current && typeof current === 'string') {
|
||||
setSelectedFolderId(current)
|
||||
return
|
||||
}
|
||||
// Set default INBOX if empty
|
||||
const defaultValue = 'INBOX'
|
||||
setSelectedFolderId(defaultValue)
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
|
||||
}
|
||||
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
|
||||
|
||||
// Handle folder selection
|
||||
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
|
||||
setSelectedFolderId(folderId)
|
||||
setFolderInfo(info || null)
|
||||
if (!isPreview) {
|
||||
setValue(blockId, subBlock.id, folderId)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-40
@@ -48,6 +48,8 @@ interface JiraProjectSelectorProps {
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function JiraProjectSelector({
|
||||
@@ -61,11 +63,12 @@ export function JiraProjectSelector({
|
||||
domain,
|
||||
showPreview = true,
|
||||
onProjectInfoChange,
|
||||
credentialId,
|
||||
}: JiraProjectSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(value)
|
||||
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -124,25 +127,7 @@ export function JiraProjectSelector({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do not auto-select credentials. Only use the credentialId provided by the parent.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
@@ -187,30 +172,34 @@ export function JiraProjectSelector({
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the project endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
projectId,
|
||||
...(cloudId && { cloudId }),
|
||||
// Use POST /api/tools/jira/projects to fetch a single project by id
|
||||
const response = await fetch(`/api/tools/jira/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/tools/jira/project?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch project details')
|
||||
}
|
||||
|
||||
const projectInfo = await response.json()
|
||||
const json = await response.json()
|
||||
const projectInfo = json?.project
|
||||
const newCloudId = json?.cloudId
|
||||
|
||||
if (projectInfo.cloudId) {
|
||||
setCloudId(projectInfo.cloudId)
|
||||
if (newCloudId) {
|
||||
setCloudId(newCloudId)
|
||||
}
|
||||
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching project details:', error)
|
||||
setError((error as Error).message)
|
||||
@@ -329,17 +318,29 @@ export function JiraProjectSelector({
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
// Fetch credentials list when dropdown opens (for account switching UI), not on mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
if (open) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [open, fetchCredentials])
|
||||
|
||||
// Keep local credential state in sync with persisted credential
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Fetch the selected project metadata once credentials are ready or changed
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId && !selectedProject && domain && domain.includes('.')) {
|
||||
if (
|
||||
value &&
|
||||
selectedCredentialId &&
|
||||
domain &&
|
||||
domain.includes('.') &&
|
||||
(!selectedProject || selectedProject.id !== value)
|
||||
) {
|
||||
fetchProjectInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedProject, domain, fetchProjectInfo])
|
||||
@@ -354,8 +355,9 @@ export function JiraProjectSelector({
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
// Only fetch projects when a credential is present; otherwise, do nothing
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchProjects('') // Pass empty string to get all projects
|
||||
fetchProjects('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,13 +396,18 @@ export function JiraProjectSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !domain}
|
||||
disabled={disabled || !domain || !selectedCredentialId}
|
||||
>
|
||||
{selectedProject ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedProject.name}</span>
|
||||
</div>
|
||||
) : selectedProjectId ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedProjectId}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
|
||||
+4
-2
@@ -24,6 +24,7 @@ interface LinearProjectSelectorProps {
|
||||
teamId: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function LinearProjectSelector({
|
||||
@@ -33,6 +34,7 @@ export function LinearProjectSelector({
|
||||
teamId,
|
||||
label = 'Select Linear project',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
}: LinearProjectSelectorProps) {
|
||||
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -49,7 +51,7 @@ export function LinearProjectSelector({
|
||||
fetch('/api/tools/linear/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential, teamId }),
|
||||
body: JSON.stringify({ credential, teamId, workflowId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
@@ -80,7 +82,7 @@ export function LinearProjectSelector({
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, teamId, value])
|
||||
}, [credential, teamId, value, workflowId])
|
||||
|
||||
// Sync selected project with value prop
|
||||
useEffect(() => {
|
||||
|
||||
+4
-2
@@ -23,6 +23,7 @@ interface LinearTeamSelectorProps {
|
||||
credential: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
workflowId?: string
|
||||
showPreview?: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ export function LinearTeamSelector({
|
||||
credential,
|
||||
label = 'Select Linear team',
|
||||
disabled = false,
|
||||
workflowId,
|
||||
}: LinearTeamSelectorProps) {
|
||||
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -48,7 +50,7 @@ export function LinearTeamSelector({
|
||||
fetch('/api/tools/linear/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential }),
|
||||
body: JSON.stringify({ credential, workflowId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
@@ -76,7 +78,7 @@ export function LinearTeamSelector({
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, value])
|
||||
}, [credential, value, workflowId])
|
||||
|
||||
// Sync selected team with value prop
|
||||
useEffect(() => {
|
||||
|
||||
+70
-18
@@ -21,7 +21,7 @@ import {
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface ProjectSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -40,34 +40,73 @@ export function ProjectSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: ProjectSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
|
||||
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
|
||||
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
// Local setters for related Jira fields to ensure immediate UI clearing
|
||||
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
|
||||
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
|
||||
blockId,
|
||||
'manualIssueKey'
|
||||
)
|
||||
// Reactive dependencies from store for Linear
|
||||
const [linearCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'jira'
|
||||
const isDiscord = provider === 'discord'
|
||||
const isLinear = provider === 'linear'
|
||||
|
||||
// For Jira, we need the domain
|
||||
const domain = !isDiscord ? (getValue(blockId, 'domain') as string) || '' : ''
|
||||
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
|
||||
// Jira/Discord upstream fields
|
||||
const [jiraDomain] = useSubBlockValue(blockId, 'domain')
|
||||
const [jiraCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const domain = (jiraDomain as string) || ''
|
||||
const botToken = ''
|
||||
|
||||
// Verify Jira credential belongs to current user; if not, treat as absent
|
||||
useEffect(() => {
|
||||
const cred = (jiraCredential as string) || ''
|
||||
if (!cred) {
|
||||
setIsForeignCredential(false)
|
||||
return
|
||||
}
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`)
|
||||
if (aborted) return
|
||||
if (!resp.ok) {
|
||||
setIsForeignCredential(true)
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
setIsForeignCredential(!(data.credentials && data.credentials.length === 1))
|
||||
} catch {
|
||||
setIsForeignCredential(true)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockId, jiraCredential])
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedProjectId(previewValue)
|
||||
} else if (typeof storeValue === 'string') {
|
||||
setSelectedProjectId(storeValue)
|
||||
} else {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedProjectId(value)
|
||||
}
|
||||
setSelectedProjectId('')
|
||||
}
|
||||
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectChange = (
|
||||
@@ -82,7 +121,12 @@ export function ProjectSelectorInput({
|
||||
if (provider === 'jira') {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
// Clear both the basic and advanced issue key fields to ensure UI resets
|
||||
collaborativeSetSubblockValue(blockId, 'issueKey', '')
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
// Also clear locally for immediate UI feedback on this client
|
||||
setIssueKeyValue('')
|
||||
setManualIssueKeyValue('')
|
||||
} else if (provider === 'discord') {
|
||||
collaborativeSetSubblockValue(blockId, 'channelId', '')
|
||||
} else if (provider === 'linear') {
|
||||
@@ -139,15 +183,16 @@ export function ProjectSelectorInput({
|
||||
onChange={(teamId: string, teamInfo?: LinearTeamInfo) => {
|
||||
handleProjectChange(teamId, teamInfo)
|
||||
}}
|
||||
credential={getValue(blockId, 'credential') as string}
|
||||
credential={(linearCredential as string) || ''}
|
||||
label={subBlock.placeholder || 'Select Linear team'}
|
||||
disabled={disabled || !getValue(blockId, 'credential')}
|
||||
disabled={disabled || !(linearCredential as string)}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const credential = getValue(blockId, 'credential') as string
|
||||
const teamId = getValue(blockId, 'teamId') as string
|
||||
const credential = (linearCredential as string) || ''
|
||||
const teamId = (linearTeamId as string) || ''
|
||||
const isDisabled = disabled || !credential || !teamId
|
||||
return (
|
||||
<LinearProjectSelector
|
||||
@@ -159,13 +204,14 @@ export function ProjectSelectorInput({
|
||||
teamId={teamId}
|
||||
label={subBlock.placeholder || 'Select Linear project'}
|
||||
disabled={isDisabled}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!getValue(blockId, 'credential') && (
|
||||
{!(linearCredential as string) && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Linear account first</p>
|
||||
</TooltipContent>
|
||||
@@ -189,17 +235,23 @@ export function ProjectSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={disabled}
|
||||
disabled={disabled || !domain || !(jiraCredential as string)}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
credentialId={(jiraCredential as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain && (
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
) : !(jiraCredential as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira account first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
+31
-29
@@ -18,6 +18,7 @@ import {
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('ToolCredentialSelector')
|
||||
|
||||
@@ -72,6 +73,7 @@ export function ToolCredentialSelector({
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
// Update selected ID when value changes
|
||||
useEffect(() => {
|
||||
@@ -86,26 +88,24 @@ export function ToolCredentialSelector({
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials || [])
|
||||
|
||||
// If we have a value but it's not in the credentials, reset it
|
||||
if (value && !data.credentials?.some((cred: Credential) => cred.id === value)) {
|
||||
onChange('')
|
||||
}
|
||||
|
||||
// Auto-selection logic (like credential-selector):
|
||||
// 1. If we already have a valid selection, keep it
|
||||
// 2. If there's a default credential, select it
|
||||
// 3. If there's only one credential, select it
|
||||
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
|
||||
if (
|
||||
(!value || !data.credentials?.some((cred: Credential) => cred.id === value)) &&
|
||||
data.credentials &&
|
||||
data.credentials.length > 0
|
||||
value &&
|
||||
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
onChange(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
// If only one credential, select it
|
||||
onChange(data.credentials[0].id)
|
||||
try {
|
||||
const metaResp = await fetch(
|
||||
`/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
|
||||
)
|
||||
if (metaResp.ok) {
|
||||
const meta = await metaResp.json()
|
||||
if (meta.credentials?.length) {
|
||||
setCredentials([meta.credentials[0], ...(data.credentials || [])])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -164,6 +164,7 @@ export function ToolCredentialSelector({
|
||||
}
|
||||
|
||||
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
|
||||
const isForeign = !!(selectedId && !selectedCredential)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -177,17 +178,18 @@ export function ToolCredentialSelector({
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedCredential ? (
|
||||
<>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='truncate font-normal'>{selectedCredential.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
{getProviderIcon(provider)}
|
||||
<span
|
||||
className={
|
||||
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
|
||||
+59
-40
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -66,59 +66,78 @@ export function TriggerConfig({
|
||||
const [actualTriggerId, setActualTriggerId] = useState<string | null>(null)
|
||||
|
||||
// Check if webhook exists in the database (using existing webhook API)
|
||||
useEffect(() => {
|
||||
const refreshWebhookState = useCallback(async () => {
|
||||
// Skip API calls in preview mode
|
||||
if (isPreview) {
|
||||
if (isPreview || !effectiveTriggerId) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const checkWebhook = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Check if there's a webhook for this specific block
|
||||
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.webhooks && data.webhooks.length > 0) {
|
||||
const webhook = data.webhooks[0].webhook
|
||||
setTriggerId(webhook.id)
|
||||
setActualTriggerId(webhook.provider)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.webhooks && data.webhooks.length > 0) {
|
||||
const webhook = data.webhooks[0].webhook
|
||||
setTriggerId(webhook.id)
|
||||
setActualTriggerId(webhook.provider)
|
||||
|
||||
// Update the path in the block state if it's different
|
||||
if (webhook.path && webhook.path !== triggerPath) {
|
||||
setTriggerPath(webhook.path)
|
||||
}
|
||||
if (webhook.path && webhook.path !== triggerPath) {
|
||||
setTriggerPath(webhook.path)
|
||||
}
|
||||
|
||||
// Update trigger config (from webhook providerConfig)
|
||||
if (webhook.providerConfig) {
|
||||
setTriggerConfig(webhook.providerConfig)
|
||||
}
|
||||
} else {
|
||||
setTriggerId(null)
|
||||
setActualTriggerId(null)
|
||||
if (webhook.providerConfig) {
|
||||
setTriggerConfig(webhook.providerConfig)
|
||||
}
|
||||
} else {
|
||||
setTriggerId(null)
|
||||
setActualTriggerId(null)
|
||||
|
||||
// Clear stale trigger data from store when no webhook found in database
|
||||
if (triggerPath) {
|
||||
setTriggerPath('')
|
||||
logger.info('Cleared stale trigger path on page refresh - no webhook in database', {
|
||||
blockId,
|
||||
clearedPath: triggerPath,
|
||||
})
|
||||
}
|
||||
if (triggerPath) {
|
||||
setTriggerPath('')
|
||||
logger.info('Cleared stale trigger path on page refresh - no webhook in database', {
|
||||
blockId,
|
||||
clearedPath: triggerPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking webhook:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking webhook:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [
|
||||
isPreview,
|
||||
effectiveTriggerId,
|
||||
workflowId,
|
||||
blockId,
|
||||
triggerPath,
|
||||
setTriggerPath,
|
||||
setTriggerConfig,
|
||||
])
|
||||
|
||||
if (effectiveTriggerId) {
|
||||
checkWebhook()
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
refreshWebhookState()
|
||||
}, [refreshWebhookState])
|
||||
|
||||
// Re-check when collaborative store updates trigger fields (so other users' changes reflect)
|
||||
// Avoid overriding local edits while the modal is open or when saving/deleting
|
||||
useEffect(() => {
|
||||
if (!isModalOpen && !isSaving && !isDeleting) {
|
||||
refreshWebhookState()
|
||||
}
|
||||
}, [workflowId, blockId, isPreview, effectiveTriggerId])
|
||||
}, [
|
||||
storeTriggerId,
|
||||
storeTriggerPath,
|
||||
storeTriggerConfig,
|
||||
isModalOpen,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
refreshWebhookState,
|
||||
])
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
+17
@@ -157,8 +157,25 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
|
||||
collaborativeToggleBlockWide,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
collaborativeToggleBlockTriggerMode,
|
||||
collaborativeSetSubblockValue,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Clear credential-dependent fields when credential changes
|
||||
const prevCredRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return
|
||||
const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id]
|
||||
if (!current) return
|
||||
const cred = current.credential?.value as string | undefined
|
||||
if (prevCredRef.current !== cred) {
|
||||
prevCredRef.current = cred
|
||||
const keys = Object.keys(current)
|
||||
const dependentKeys = keys.filter((k) => k !== 'credential')
|
||||
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
||||
}
|
||||
}, [id, collaborativeSetSubblockValue])
|
||||
|
||||
// Workflow store actions
|
||||
const updateBlockHeight = useWorkflowStore((state) => state.updateBlockHeight)
|
||||
|
||||
|
||||
@@ -111,19 +111,15 @@ export async function executeTool(
|
||||
try {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const isServerSide = typeof window === 'undefined'
|
||||
|
||||
// Prepare the token payload
|
||||
const tokenPayload: OAuthTokenPayload = {
|
||||
credentialId: contextParams.credential,
|
||||
}
|
||||
|
||||
// Add workflowId if it exists in params or context (only server-side)
|
||||
if (isServerSide) {
|
||||
const workflowId = contextParams.workflowId || contextParams._context?.workflowId
|
||||
if (workflowId) {
|
||||
tokenPayload.workflowId = workflowId
|
||||
}
|
||||
// Add workflowId if it exists in params or context
|
||||
const workflowId = contextParams.workflowId || contextParams._context?.workflowId
|
||||
if (workflowId) {
|
||||
tokenPayload.workflowId = workflowId
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching access token from ${baseUrl}/api/auth/oauth/token`)
|
||||
|
||||
Reference in New Issue
Block a user