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:
Vikhyath Mondreti
2025-08-13 16:51:46 -05:00
committed by GitHub
parent 5dfe9330bb
commit b159d63fbb
32 changed files with 1023 additions and 531 deletions
@@ -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',
}
+22 -23
View File
@@ -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')
})
})
})
+12 -14
View File
@@ -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
+7 -7
View File
@@ -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 })
+9 -5
View File
@@ -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 })
+61 -26
View File
@@ -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',
+31 -4
View File
@@ -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',
+123 -50
View File
@@ -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`, {
@@ -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>
@@ -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)
@@ -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()}`)
@@ -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>
@@ -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 ? (
@@ -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,
@@ -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)
@@ -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}
/>
)
}
@@ -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)
}
}
@@ -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' />
@@ -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(() => {
@@ -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(() => {
@@ -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>
)
@@ -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>
@@ -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
@@ -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)
+4 -8
View File
@@ -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`)