Compare commits

..

29 Commits

Author SHA1 Message Date
Vikhyath Mondreti
d8bbd7eeec add filter 2026-02-14 14:26:22 -08:00
Vikhyath Mondreti
9584b99c8a address bugbot 2026-02-14 14:10:56 -08:00
Vikhyath Mondreti
140f870cfc remove more unused code 2026-02-14 12:54:28 -08:00
Vikhyath Mondreti
d235d747ca more permissions stuff 2026-02-14 12:47:03 -08:00
Vikhyath Mondreti
3769da88b0 remove some dead code 2026-02-14 12:32:55 -08:00
Vikhyath Mondreti
41cdca20d6 address bugbot comments 2026-02-14 12:27:03 -08:00
Vikhyath Mondreti
cd1ccf1f1f autoselect provider when connecting from block 2026-02-14 12:15:57 -08:00
Vikhyath Mondreti
6053050718 remove unused code 2026-02-14 11:44:28 -08:00
Vikhyath Mondreti
08b908fdce fix tests 2026-02-14 00:00:03 -08:00
Vikhyath Mondreti
ea42e64540 run lint 2026-02-13 18:05:52 -08:00
Vikhyath Mondreti
d70a5d4271 backfill improvements 2026-02-13 16:26:13 -08:00
Vikhyath Mondreti
93826cbd1a migration readded 2026-02-13 15:25:13 -08:00
Vikhyath Mondreti
7092c88b9b Merge remote-tracking branch 'origin/staging' into feat/mult-credentials-rv 2026-02-13 15:05:12 -08:00
Vikhyath Mondreti
084ff9c9d0 remove migration to prep stagin migration 2026-02-13 14:37:03 -08:00
Vikhyath Mondreti
3ad0f62545 canonical credential id entry 2026-02-13 14:20:57 -08:00
Vikhyath Mondreti
ff13b1f43b remove credential no access marker 2026-02-13 12:17:26 -08:00
Vikhyath Mondreti
fa32b9e687 reconnect option to connect diff account 2026-02-13 12:12:56 -08:00
Vikhyath Mondreti
dcf40be189 copilot + oauth name comflict 2026-02-12 18:42:52 -08:00
Vikhyath Mondreti
77bb048307 share button 2026-02-12 18:04:02 -08:00
Vikhyath Mondreti
17710b39a5 remove new badge 2026-02-12 17:05:54 -08:00
Vikhyath Mondreti
bdd14839a3 share with workspace for oauth 2026-02-12 17:01:55 -08:00
Vikhyath Mondreti
8ed8a5a1ce more ux improvmeent 2026-02-12 16:52:54 -08:00
Vikhyath Mondreti
5e19226dd1 promote to workspace secret 2026-02-12 16:50:13 -08:00
Vikhyath Mondreti
622023d998 bulk entry of .env 2026-02-12 16:39:10 -08:00
Vikhyath Mondreti
319768c2bd remove add member ui for workspace secrets 2026-02-12 15:28:15 -08:00
Vikhyath Mondreti
aefa281677 improve collaborative UX 2026-02-12 15:18:54 -08:00
Vikhyath Mondreti
508772cf58 make it autoselect personal secret when create secret is clicked 2026-02-11 20:06:27 -08:00
Vikhyath Mondreti
7314675f50 checkpoint 2026-02-11 19:58:24 -08:00
Vikhyath Mondreti
253161afba feat(mult-credentials): progress 2026-02-11 15:18:31 -08:00
135 changed files with 18570 additions and 2748 deletions

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account } from '@sim/db/schema' import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
@@ -31,15 +31,13 @@ export async function GET(request: NextRequest) {
}) })
.from(account) .from(account)
.where(and(...whereConditions)) .where(and(...whereConditions))
.orderBy(desc(account.updatedAt))
// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
const accountsWithDisplayName = accounts.map((acc) => ({ const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id, id: acc.id,
accountId: acc.accountId, accountId: acc.accountId,
providerId: acc.providerId, providerId: acc.providerId,
displayName: userEmail || acc.providerId, displayName: acc.accountId || acc.providerId,
})) }))
return NextResponse.json({ accounts: accountsWithDisplayName }) return NextResponse.json({ accounts: accountsWithDisplayName })

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, user } from '@sim/db/schema' import { account, credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode' import { jwtDecode } from 'jwt-decode'
@@ -7,8 +7,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth' import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -18,6 +20,7 @@ const credentialsQuerySchema = z
.object({ .object({
provider: z.string().nullish(), provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(), workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z credentialId: z
.string() .string()
.min(1, 'Credential ID must not be empty') .min(1, 'Credential ID must not be empty')
@@ -35,6 +38,79 @@ interface GoogleIdToken {
name?: string name?: string
} }
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}
async function getFallbackDisplayName(
requestId: string,
providerParam: string | null | undefined,
accountRow: {
idToken: string | null
accountId: string
userId: string
}
) {
const providerForParse = (providerParam || 'google') as OAuthProvider
const { baseProvider } = parseProvider(providerForParse)
if (accountRow.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(accountRow.idToken)
if (decoded.email) return decoded.email
if (decoded.name) return decoded.name
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: accountRow.accountId,
})
}
}
if (baseProvider === 'github') {
return `${accountRow.accountId} (GitHub)`
}
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, accountRow.userId))
.limit(1)
if (userRecord.length > 0) {
return userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: accountRow.userId,
})
}
return `${accountRow.accountId} (${baseProvider})`
}
/** /**
* Get credentials for a specific provider * Get credentials for a specific provider
*/ */
@@ -46,6 +122,7 @@ export async function GET(request: NextRequest) {
const rawQuery = { const rawQuery = {
provider: searchParams.get('provider'), provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'), workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'), credentialId: searchParams.get('credentialId'),
} }
@@ -78,7 +155,7 @@ export async function GET(request: NextRequest) {
) )
} }
const { provider: providerParam, workflowId, credentialId } = parseResult.data const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT) // Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request) const authResult = await checkSessionOrInternalAuth(request)
@@ -88,7 +165,7 @@ export async function GET(request: NextRequest) {
} }
const requesterUserId = authResult.userId const requesterUserId = authResult.userId
const effectiveUserId = requesterUserId let effectiveWorkspaceId = workspaceId ?? undefined
if (workflowId) { if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId, workflowId,
@@ -106,101 +183,145 @@ export async function GET(request: NextRequest) {
{ status: workflowAuthorization.status } { status: workflowAuthorization.status }
) )
} }
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
} }
// Parse the provider to get base provider and feature type (if provider is present) if (effectiveWorkspaceId) {
const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider) const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
let accountsData let accountsData
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
const credentialsData = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam)
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
)
}
if (credentialId && workflowId) { if (credentialId && workflowId) {
// When both workflowId and credentialId are provided, fetch by ID only.
// Workspace authorization above already proves access; the credential
// may belong to another workspace member (e.g. for display name resolution).
accountsData = await db.select().from(account).where(eq(account.id, credentialId)) accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else if (credentialId) { } else if (credentialId) {
accountsData = await db accountsData = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId))) .where(and(eq(account.userId, requesterUserId), eq(account.id, credentialId)))
} else { } else {
// Fetch all credentials for provider and effective user
accountsData = await db accountsData = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!))) .where(and(eq(account.userId, requesterUserId), eq(account.providerId, providerParam!)))
} }
// Transform accounts into credentials // Transform accounts into credentials
const credentials = await Promise.all( const credentials = await Promise.all(
accountsData.map(async (acc) => { accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default') const displayName = await getFallbackDisplayName(requestId, providerParam, acc)
const [_, featureType = 'default'] = acc.providerId.split('-') return toCredentialResponse(acc.id, displayName, acc.providerId, acc.updatedAt, acc.scope)
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)
if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: acc.userId,
})
}
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
const storedScope = acc.scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}) })
) )

View File

@@ -15,6 +15,7 @@ const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({ const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'), provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(), providerId: z.string().optional(),
accountId: z.string().optional(),
}) })
/** /**
@@ -50,15 +51,20 @@ export async function POST(request: NextRequest) {
) )
} }
const { provider, providerId } = parseResult.data const { provider, providerId, accountId } = parseResult.data
logger.info(`[${requestId}] Processing OAuth disconnect request`, { logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider, provider,
hasProviderId: !!providerId, hasProviderId: !!providerId,
}) })
// If a specific providerId is provided, delete only that account // If a specific account row ID is provided, delete that exact account
if (providerId) { if (accountId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
// If a specific providerId is provided, delete accounts for that provider ID
await db await db
.delete(account) .delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))

View File

@@ -38,13 +38,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
} }
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
const accessToken = await refreshAccessTokenIfNeeded( const accessToken = await refreshAccessTokenIfNeeded(
credentialId, resolvedCredentialId,
authz.credentialOwnerUserId, authz.credentialOwnerUserId,
requestId requestId
) )

View File

@@ -37,14 +37,19 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
} }
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
// Refresh access token if needed using the utility function // Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded( const accessToken = await refreshAccessTokenIfNeeded(
credentialId, resolvedCredentialId,
authz.credentialOwnerUserId, authz.credentialOwnerUserId,
requestId requestId
) )

View File

@@ -351,10 +351,11 @@ describe('OAuth Token API Routes', () => {
*/ */
describe('GET handler', () => { describe('GET handler', () => {
it('should return access token successfully', async () => { it('should return access token successfully', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',
@@ -380,8 +381,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token') expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled() expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id') expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled() expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
}) })
@@ -399,8 +400,8 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle authentication failure', async () => { it('should handle authentication failure', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: false, ok: false,
error: 'Authentication required', error: 'Authentication required',
}) })
@@ -413,15 +414,16 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any) const response = await GET(req as any)
const data = await response.json() const data = await response.json()
expect(response.status).toBe(401) expect(response.status).toBe(403)
expect(data).toHaveProperty('error') expect(data).toHaveProperty('error')
}) })
it('should handle credential not found', async () => { it('should handle credential not found', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce(undefined) mockGetCredential.mockResolvedValueOnce(undefined)
@@ -439,10 +441,11 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle missing access token', async () => { it('should handle missing access token', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',
@@ -465,10 +468,11 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle token refresh failure', async () => { it('should handle token refresh failure', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',

View File

@@ -110,23 +110,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
} }
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const authz = await authorizeCredentialUse(request, { const authz = await authorizeCredentialUse(request, {
credentialId, credentialId,
workflowId: workflowId ?? undefined, workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false, requireWorkflowIdForInternal: false,
callerUserId,
}) })
if (!authz.ok || !authz.credentialOwnerUserId) { if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
} }
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
try { try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
let instanceUrl: string | undefined let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) { if (credential.providerId === 'salesforce' && credential.scope) {
@@ -186,13 +198,20 @@ export async function GET(request: NextRequest) {
const { credentialId } = parseResult.data const { credentialId } = parseResult.data
// For GET requests, we only support session-based authentication const authz = await authorizeCredentialUse(request, {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) credentialId,
if (!auth.success || auth.authType !== 'session' || !auth.userId) { requireWorkflowIdForInternal: false,
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) })
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
} }
const credential = await getCredential(requestId, credentialId, auth.userId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -204,7 +223,11 @@ export async function GET(request: NextRequest) {
} }
try { try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
// For Salesforce, extract instanceUrl from the scope field // For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined let instanceUrl: string | undefined

View File

@@ -62,21 +62,23 @@ describe('OAuth Utils', () => {
describe('getCredential', () => { describe('getCredential', () => {
it('should return credential when found', async () => { it('should return credential when found', async () => {
const mockCredential = { id: 'credential-id', userId: 'test-user-id' } const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential]) const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id') const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDb.select).toHaveBeenCalled() expect(mockDb.select).toHaveBeenCalledTimes(2)
expect(mockFrom).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(mockLimit).toHaveBeenCalledWith(1)
expect(credential).toEqual(mockCredential) expect(credential).toMatchObject(mockAccountRow)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
}) })
it('should return undefined when credential is not found', async () => { it('should return undefined when credential is not found', async () => {
mockSelectChain([]) mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
@@ -158,15 +160,17 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => { describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => { it('should return valid access token without refresh if not expired', async () => {
const mockCredential = { const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
id: 'credential-id', const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token', accessToken: 'valid-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockSelectChain([mockCredential]) mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -175,15 +179,17 @@ describe('OAuth Utils', () => {
}) })
it('should refresh token when expired', async () => { it('should refresh token when expired', async () => {
const mockCredential = { const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
id: 'credential-id', const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token', accessToken: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockSelectChain([mockCredential]) mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockUpdateChain() mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({ mockRefreshOAuthToken.mockResolvedValueOnce({
@@ -201,6 +207,7 @@ describe('OAuth Utils', () => {
it('should return null if credential not found', async () => { it('should return null if credential not found', async () => {
mockSelectChain([]) mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -208,15 +215,17 @@ describe('OAuth Utils', () => {
}) })
it('should return null if refresh fails', async () => { it('should return null if refresh fails', async () => {
const mockCredential = { const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
id: 'credential-id', const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token', accessToken: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockSelectChain([mockCredential]) mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null) mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, credentialSetMember } from '@sim/db/schema' import { account, credential, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm' import { and, desc, eq, inArray } from 'drizzle-orm'
import { refreshOAuthToken } from '@/lib/oauth' import { refreshOAuthToken } from '@/lib/oauth'
@@ -25,6 +25,28 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date accessTokenExpiresAt?: Date
} }
async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; usedCredentialTable: boolean } | null> {
const [credentialRow] = await db
.select({
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return { accountId: credentialRow.accountId, usedCredentialTable: true }
}
return { accountId: credentialId, usedCredentialTable: false }
}
/** /**
* Safely inserts an account record, handling duplicate constraint violations gracefully. * Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success. * If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -52,10 +74,16 @@ export async function safeAccountInsert(
* Get a credential by ID and verify it belongs to the user * Get a credential by ID and verify it belongs to the user
*/ */
export async function getCredential(requestId: string, credentialId: string, userId: string) { export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
const credentials = await db const credentials = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, userId))) .where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
.limit(1) .limit(1)
if (!credentials.length) { if (!credentials.length) {
@@ -63,7 +91,10 @@ export async function getCredential(requestId: string, credentialId: string, use
return undefined return undefined
} }
return credentials[0] return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
}
} }
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> { export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
@@ -238,7 +269,9 @@ export async function refreshAccessTokenIfNeeded(
} }
// Update the token in the database // Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId)) const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token for credential`) logger.info(`[${requestId}] Successfully refreshed access token for credential`)
return refreshedToken.accessToken return refreshedToken.accessToken
@@ -274,6 +307,8 @@ export async function refreshTokenIfNeeded(
credential: any, credential: any,
credentialId: string credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> { ): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired // Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
@@ -334,7 +369,7 @@ export async function refreshTokenIfNeeded(
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
} }
await db.update(account).set(updateData).where(eq(account.id, credentialId)) await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token`) logger.info(`[${requestId}] Successfully refreshed access token`)
return { accessToken: refreshedToken, refreshed: true } return { accessToken: refreshedToken, refreshed: true }
@@ -343,7 +378,7 @@ export async function refreshTokenIfNeeded(
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded` `[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
) )
const freshCredential = await getCredential(requestId, credentialId, credential.userId) const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId)
if (freshCredential?.accessToken) { if (freshCredential?.accessToken) {
const freshExpiresAt = freshCredential.accessTokenExpiresAt const freshExpiresAt = freshCredential.accessTokenExpiresAt
const stillValid = !freshExpiresAt || freshExpiresAt > new Date() const stillValid = !freshExpiresAt || freshExpiresAt > new Date()

View File

@@ -48,16 +48,21 @@ export async function GET(request: NextRequest) {
const shopData = await shopResponse.json() const shopData = await shopResponse.json()
const shopInfo = shopData.shop const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({ const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')), where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
}) })
const now = new Date() const now = new Date()
const accountData = { const accountData = {
accessToken: accessToken, accessToken: accessToken,
accountId: shopInfo.id?.toString() || shopDomain, accountId: stableAccountId,
scope: scope || '', scope: scope || '',
updatedAt: now, updatedAt: now,
idToken: shopDomain, idToken: shopDomain,

View File

@@ -52,7 +52,11 @@ export async function POST(request: NextRequest) {
const trelloUser = await userResponse.json() const trelloUser = await userResponse.json()
const existing = await db.query.account.findFirst({ const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')), where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
}) })
const now = new Date() const now = new Date()

View File

@@ -0,0 +1,226 @@
import { db } from '@sim/db'
import { credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireWorkspaceAdminMembership(credentialId: string, userId: string) {
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) return null
const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId)
if (perm === null) return null
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
return membership
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ members: [] }, { status: 200 })
}
const callerPerm = await getUserEntityPermissions(
session.user.id,
'workspace',
cred.workspaceId
)
if (callerPerm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const members = await db
.select({
id: credentialMember.id,
userId: credentialMember.userId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
.where(eq(credentialMember.credentialId, credentialId))
return NextResponse.json({ members })
} catch (error) {
logger.error('Failed to fetch credential members', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const addMemberSchema = z.object({
userId: z.string().min(1),
role: z.enum(['admin', 'member']).default('member'),
})
export async function POST(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await request.json()
const parsed = addMemberSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, role } = parsed.data
const now = new Date()
const [existing] = await db
.select({ id: credentialMember.id, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (existing) {
await db
.update(credentialMember)
.set({ role, status: 'active', updatedAt: now })
.where(eq(credentialMember.id, existing.id))
return NextResponse.json({ success: true })
}
await db.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId,
role,
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const targetUserId = new URL(request.url).searchParams.get('userId')
if (!targetUserId) {
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
}
const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const [target] = await db
.select({
id: credentialMember.id,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
const revoked = await db.transaction(async (tx) => {
if (target.role === 'admin') {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return false
}
}
await tx
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return true
})
if (!revoked) {
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,251 @@
import { db } from '@sim/db'
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
const logger = createLogger('CredentialByIdAPI')
const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
})
.strict()
.refine((data) => data.displayName !== undefined || data.description !== undefined, {
message: 'At least one field must be provided',
path: ['displayName'],
})
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credential)
.innerJoin(
credentialMember,
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
)
.where(eq(credential.id, credentialId))
.limit(1)
return row ?? null
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.member) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to fetch credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const parseResult = updateCredentialSchema.safeParse(await request.json())
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (parseResult.data.description !== undefined) {
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
updates.displayName = parseResult.data.displayName
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ status: 400 }
)
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, id))
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(environment)
.values({
id: ownerUserId,
userId: ownerUserId,
variables: current,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
return NextResponse.json({ success: true }, { status: 200 })
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || crypto.randomUUID(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
envKeys: Object.keys(current),
actingUserId: session.user.id,
})
return NextResponse.json({ success: true }, { status: 200 })
}
await db.delete(credential).where(eq(credential.id, id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,116 @@
import { db } from '@sim/db'
import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialDraftAPI')
const DRAFT_TTL_MS = 15 * 60 * 1000
const createDraftSchema = z.object({
workspaceId: z.string().min(1),
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
credentialId: z.string().min(1).optional(),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = createDraftSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { workspaceId, providerId, displayName, description, credentialId } = parsed.data
const userId = session.user.id
const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
if (credentialId) {
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.innerJoin(credential, eq(credential.id, credentialMember.credentialId))
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, userId),
eq(credentialMember.status, 'active'),
eq(credentialMember.role, 'admin'),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
if (!membership) {
return NextResponse.json(
{ error: 'Admin access required on the target credential' },
{ status: 403 }
)
}
}
const now = new Date()
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: crypto.randomUUID(),
userId,
workspaceId,
providerId,
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: {
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
logger.info('Credential draft saved', {
userId,
workspaceId,
providerId,
displayName,
credentialId: credentialId || null,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to save credential draft', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,112 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembershipsAPI')
const leaveCredentialSchema = z.object({
credentialId: z.string().min(1),
})
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const memberships = await db
.select({
membershipId: credentialMember.id,
credentialId: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
})
.from(credentialMember)
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
.where(eq(credentialMember.userId, session.user.id))
return NextResponse.json({ memberships }, { status: 200 })
} catch (error) {
logger.error('Failed to list credential memberships', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parseResult = leaveCredentialSchema.safeParse({
credentialId: new URL(request.url).searchParams.get('credentialId'),
})
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { credentialId } = parseResult.data
const [membership] = await db
.select()
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
}
if (membership.status !== 'active') {
return NextResponse.json({ success: true }, { status: 200 })
}
if (membership.role === 'admin') {
const activeAdmins = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return NextResponse.json(
{ error: 'Cannot leave credential as the last active admin' },
{ status: 400 }
)
}
}
await db
.update(credentialMember)
.set({
status: 'revoked',
updatedAt: new Date(),
})
.where(eq(credentialMember.id, membership.id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to leave credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,521 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
return wrappedMatch ? wrappedMatch[1] : trimmed
}
const listCredentialsSchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema.optional(),
providerId: z.string().optional(),
credentialId: z.string().optional(),
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'accountId is required for oauth credentials',
path: ['accountId'],
})
}
if (!data.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'providerId is required for oauth credentials',
path: ['providerId'],
})
}
if (!data.displayName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'displayName is required for oauth credentials',
path: ['displayName'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey is required for env credentials',
path: ['envKey'],
})
return
}
if (!isValidEnvVarName(normalizedEnvKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey must contain only letters, numbers, and underscores',
path: ['envKey'],
})
}
})
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth'),
eq(credential.accountId, accountId)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_workspace' && envKey) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_workspace'),
eq(credential.envKey, envKey)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_personal' && envKey && envOwnerUserId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envKey, envKey),
eq(credential.envOwnerUserId, envOwnerUserId)
)
)
.limit(1)
return row ?? null
}
return null
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const rawWorkspaceId = searchParams.get('workspaceId')
const rawType = searchParams.get('type')
const rawProviderId = searchParams.get('providerId')
const rawCredentialId = searchParams.get('credentialId')
const parseResult = listCredentialsSchema.safeParse({
workspaceId: rawWorkspaceId?.trim(),
type: rawType?.trim() || undefined,
providerId: rawProviderId?.trim() || undefined,
credentialId: rawCredentialId?.trim() || undefined,
})
if (!parseResult.success) {
logger.warn(`[${requestId}] Invalid credential list request`, {
workspaceId: rawWorkspaceId,
type: rawType,
providerId: rawProviderId,
errors: parseResult.error.errors,
})
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (lookupCredentialId) {
let [row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
if (!row) {
;[row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(
and(
eq(credential.accountId, lookupCredentialId),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
}
return NextResponse.json({ credential: row ?? null })
}
if (!type || type === 'oauth') {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
const whereClauses = [
eq(credential.workspaceId, workspaceId),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active'),
]
if (type) {
whereClauses.push(eq(credential.type, type))
}
if (providerId) {
whereClauses.push(eq(credential.providerId, providerId))
}
const credentials = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active')
)
)
.where(and(...whereClauses))
return NextResponse.json({ credentials })
} catch (error) {
logger.error(`[${requestId}] Failed to list credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const parseResult = createCredentialSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const {
workspaceId,
type,
displayName,
description,
providerId,
accountId,
envKey,
envOwnerUserId,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
let resolvedDisplayName = displayName?.trim() ?? ''
const resolvedDescription = description?.trim() || null
let resolvedProviderId: string | null = providerId ?? null
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
})
.from(account)
.where(eq(account.id, accountId!))
.limit(1)
if (!accountRow) {
return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
}
if (accountRow.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Only account owners can create oauth credentials for an account' },
{ status: 403 }
)
}
if (providerId !== accountRow.providerId) {
return NextResponse.json(
{ error: 'providerId does not match the selected OAuth account' },
{ status: 400 }
)
}
if (!resolvedDisplayName) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
return NextResponse.json(
{ error: 'Only the current user can create personal env credentials for themselves' },
{ status: 403 }
)
}
resolvedProviderId = null
resolvedAccountId = null
resolvedDisplayName = resolvedEnvKey || ''
} else {
resolvedProviderId = null
resolvedAccountId = null
resolvedEnvOwnerUserId = null
resolvedDisplayName = resolvedEnvKey || ''
}
if (!resolvedDisplayName) {
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
}
const existingCredential = await findExistingCredentialBySource({
workspaceId,
type,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
})
if (existingCredential) {
const [membership] = await db
.select({
id: credentialMember.id,
status: credentialMember.status,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, existingCredential.id),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership || membership.status !== 'active') {
return NextResponse.json(
{ error: 'A credential with this source already exists in this workspace' },
{ status: 409 }
)
}
const canUpdateExistingCredential = membership.role === 'admin'
const shouldUpdateDisplayName =
type === 'oauth' &&
resolvedDisplayName &&
resolvedDisplayName !== existingCredential.displayName
const shouldUpdateDescription =
typeof description !== 'undefined' &&
(existingCredential.description ?? null) !== resolvedDescription
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
await db
.update(credential)
.set({
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
updatedAt: new Date(),
})
.where(eq(credential.id, existingCredential.id))
const [updatedCredential] = await db
.select()
.from(credential)
.where(eq(credential.id, existingCredential.id))
.limit(1)
return NextResponse.json(
{ credential: updatedCredential ?? existingCredential },
{ status: 200 }
)
}
return NextResponse.json({ credential: existingCredential }, { status: 200 })
}
const now = new Date()
const credentialId = crypto.randomUUID()
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
await db.transaction(async (tx) => {
await tx.insert(credential).values({
id: credentialId,
workspaceId,
type,
displayName: resolvedDisplayName,
description: resolvedDescription,
providerId: resolvedProviderId,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: memberUserId,
role: memberUserId === workspaceRow.ownerId ? 'admin' : 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
}
} else {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: session.user.id,
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
})
const [created] = await db
.select()
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
{ status: 409 }
)
}
if (error?.code === '23503') {
return NextResponse.json(
{ error: 'Invalid credential reference or membership target' },
{ status: 400 }
)
}
if (error?.code === '23514') {
return NextResponse.json(
{ error: 'Credential source data failed validation checks' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Credential create failure details`, {
code: error?.code,
detail: error?.detail,
constraint: error?.constraint,
table: error?.table,
message: error?.message,
})
logger.error(`[${requestId}] Failed to create credential`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment' import type { EnvironmentVariable } from '@/stores/settings/environment'
const logger = createLogger('EnvironmentAPI') const logger = createLogger('EnvironmentAPI')
@@ -53,6 +54,11 @@ export async function POST(req: NextRequest) {
}, },
}) })
await syncPersonalEnvCredentialsForUser({
userId: session.user.id,
envKeys: Object.keys(variables),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (validationError) { } catch (validationError) {
if (validationError instanceof z.ZodError) { if (validationError instanceof z.ZodError) {

View File

@@ -11,6 +11,7 @@ import {
user, user,
userStats, userStats,
type WorkspaceInvitationStatus, type WorkspaceInvitationStatus,
workspaceEnvironment,
workspaceInvitation, workspaceInvitation,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
@@ -23,6 +24,7 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation') const logger = createLogger('OrganizationInvitation')
@@ -495,6 +497,34 @@ export async function PUT(
} }
}) })
if (status === 'accepted') {
const acceptedWsInvitations = await db
.select({ workspaceId: workspaceInvitation.workspaceId })
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.orgInvitationId, invitationId),
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
)
)
for (const wsInv of acceptedWsInvitations) {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: wsInv.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
}
}
// Handle Pro subscription cancellation after transaction commits // Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) { if (personalProToCancel) {
try { try {

View File

@@ -25,6 +25,7 @@ import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema' import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { import {
badRequestResponse, badRequestResponse,
@@ -215,6 +216,8 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (_, context) => {
await db.delete(permissions).where(eq(permissions.id, memberId)) await db.delete(permissions).where(eq(permissions.id, memberId))
await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId)
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId, userId: existingMember.userId,
}) })

View File

@@ -32,9 +32,10 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema' import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm' import { and, count, eq } from 'drizzle-orm'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { import {
badRequestResponse, badRequestResponse,
@@ -232,6 +233,20 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
permissionId, permissionId,
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: body.userId,
})
}
return singleResponse({ return singleResponse({
id: permissionId, id: permissionId,
workspaceId, workspaceId,

View File

@@ -536,6 +536,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState, useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
isClientSession, isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride, workflowStateOverride: effectiveWorkflowStateOverride,
} }
@@ -885,6 +886,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState, useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
isClientSession, isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride, workflowStateOverride: effectiveWorkflowStateOverride,
} }

View File

@@ -1,12 +1,14 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { environment, workspaceEnvironment } from '@sim/db/schema' import { workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI') const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -44,44 +46,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
// Workspace env (encrypted) const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
const wsEnvRow = await db userId,
.select() workspaceId
.from(workspaceEnvironment) )
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
// Personal env (encrypted)
const personalRow = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
// Decrypt both for UI
const decryptAll = async (src: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(src)) {
try {
const { decrypted } = await decryptSecret(v)
out[k] = decrypted
} catch {
out[k] = ''
}
}
return out
}
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
decryptAll(wsEncrypted),
decryptAll(personalEncrypted),
])
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
return NextResponse.json( return NextResponse.json(
{ {
@@ -156,6 +124,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
set: { variables: merged, updatedAt: new Date() }, set: { variables: merged, updatedAt: new Date() },
}) })
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(merged),
actingUserId: userId,
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workspace env PUT error`, error) logger.error(`[${requestId}] Workspace env PUT error`, error)
@@ -222,6 +196,12 @@ export async function DELETE(
set: { variables: current, updatedAt: new Date() }, set: { variables: current, updatedAt: new Date() },
}) })
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(current),
actingUserId: userId,
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workspace env DELETE error`, error) logger.error(`[${requestId}] Workspace env DELETE error`, error)

View File

@@ -1,11 +1,12 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema' import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { import {
getUsersWithPermissions, getUsersWithPermissions,
hasWorkspaceAdminAccess, hasWorkspaceAdminAccess,
@@ -154,6 +155,20 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
const updatedUsers = await getUsersWithPermissions(workspaceId) const updatedUsers = await getUsersWithPermissions(workspaceId)
return NextResponse.json({ return NextResponse.json({

View File

@@ -8,15 +8,27 @@ const mockHasWorkspaceAdminAccess = vi.fn()
let dbSelectResults: any[] = [] let dbSelectResults: any[] = []
let dbSelectCallIndex = 0 let dbSelectCallIndex = 0
const mockDbSelect = vi.fn().mockImplementation(() => ({ const mockDbSelect = vi.fn().mockImplementation(() => {
from: vi.fn().mockReturnThis(), const makeThen = () =>
where: vi.fn().mockReturnThis(), vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
then: vi.fn().mockImplementation((callback: (rows: any[]) => any) => { const result = dbSelectResults[dbSelectCallIndex] || []
const result = dbSelectResults[dbSelectCallIndex] || [] dbSelectCallIndex++
dbSelectCallIndex++ return Promise.resolve(callback ? callback(result) : result)
return Promise.resolve(callback ? callback(result) : result) })
}), const makeLimit = () =>
})) vi.fn().mockImplementation(() => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(result)
})
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = makeLimit()
chain.then = makeThen()
return chain
})
const mockDbInsert = vi.fn().mockImplementation(() => ({ const mockDbInsert = vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue(undefined), values: vi.fn().mockResolvedValue(undefined),
@@ -53,6 +65,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
mockHasWorkspaceAdminAccess(userId, workspaceId), mockHasWorkspaceAdminAccess(userId, workspaceId),
})) }))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/utils/urls', () => ({ vi.mock('@/lib/core/utils/urls', () => ({
@@ -95,6 +111,10 @@ vi.mock('@sim/db/schema', () => ({
userId: 'userId', userId: 'userId',
permissionType: 'permissionType', permissionType: 'permissionType',
}, },
workspaceEnvironment: {
workspaceId: 'workspaceId',
variables: 'variables',
},
})) }))
vi.mock('drizzle-orm', () => ({ vi.mock('drizzle-orm', () => ({
@@ -207,6 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace], [mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }], [{ ...mockUser, email: 'invited@example.com' }],
[], [],
[],
] ]
const request = new NextRequest( const request = new NextRequest(
@@ -460,6 +481,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace], [mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }], [{ ...mockUser, email: 'invited@example.com' }],
[], [],
[],
] ]
const request2 = new NextRequest( const request2 = new NextRequest(

View File

@@ -6,6 +6,7 @@ import {
user, user,
type WorkspaceInvitationStatus, type WorkspaceInvitationStatus,
workspace, workspace,
workspaceEnvironment,
workspaceInvitation, workspaceInvitation,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
@@ -14,6 +15,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails' import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -162,6 +164,20 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id)) .where(eq(workspaceInvitation.id, invitation.id))
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: invitation.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl())) return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
} }

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceMemberAPI') const logger = createLogger('WorkspaceMemberAPI')
@@ -101,6 +102,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
) )
) )
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
logger.error('Error removing workspace member:', error) logger.error('Error removing workspace member:', error)

View File

@@ -1,2 +1,2 @@
export type { StatusBarSegment } from './status-bar' export type { StatusBarSegment } from './status-bar'
export { StatusBar } from './status-bar' export { default, StatusBar } from './status-bar'

View File

@@ -8,7 +8,7 @@ export interface StatusBarSegment {
timestamp: string timestamp: string
} }
function StatusBarInner({ export function StatusBar({
segments, segments,
selectedSegmentIndices, selectedSegmentIndices,
onSegmentClick, onSegmentClick,
@@ -127,45 +127,4 @@ function StatusBarInner({
) )
} }
/** export default memo(StatusBar)
* Custom equality function for StatusBar memo.
* Performs structural comparison of segments array to avoid re-renders
* when poll data returns new object references with identical content.
*/
function areStatusBarPropsEqual(
prev: Parameters<typeof StatusBarInner>[0],
next: Parameters<typeof StatusBarInner>[0]
): boolean {
if (prev.workflowId !== next.workflowId) return false
if (prev.segmentDurationMs !== next.segmentDurationMs) return false
if (prev.preferBelow !== next.preferBelow) return false
if (prev.selectedSegmentIndices !== next.selectedSegmentIndices) {
if (!prev.selectedSegmentIndices || !next.selectedSegmentIndices) return false
if (prev.selectedSegmentIndices.length !== next.selectedSegmentIndices.length) return false
for (let i = 0; i < prev.selectedSegmentIndices.length; i++) {
if (prev.selectedSegmentIndices[i] !== next.selectedSegmentIndices[i]) return false
}
}
if (prev.segments !== next.segments) {
if (prev.segments.length !== next.segments.length) return false
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = next.segments[i]
if (
ps.successRate !== ns.successRate ||
ps.hasExecutions !== ns.hasExecutions ||
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp
) {
return false
}
}
}
return true
}
export const StatusBar = memo(StatusBarInner, areStatusBarPropsEqual)

View File

@@ -1,2 +1,2 @@
export type { WorkflowExecutionItem } from './workflows-list' export type { WorkflowExecutionItem } from './workflows-list'
export { WorkflowsList } from './workflows-list' export { default, WorkflowsList } from './workflows-list'

View File

@@ -14,7 +14,7 @@ export interface WorkflowExecutionItem {
overallSuccessRate: number overallSuccessRate: number
} }
function WorkflowsListInner({ export function WorkflowsList({
filteredExecutions, filteredExecutions,
expandedWorkflowId, expandedWorkflowId,
onToggleWorkflow, onToggleWorkflow,
@@ -103,7 +103,7 @@ function WorkflowsListInner({
<StatusBar <StatusBar
segments={workflow.segments} segments={workflow.segments}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null} selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick} onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId} workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs} segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2} preferBelow={idx < 2}
@@ -124,4 +124,4 @@ function WorkflowsListInner({
) )
} }
export const WorkflowsList = memo(WorkflowsListInner) export default memo(WorkflowsList)

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -141,10 +141,10 @@ function toWorkflowExecution(wf: WorkflowStats): WorkflowExecution {
} }
} }
function DashboardInner({ stats, isLoading, error }: DashboardProps) { export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({}) const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({}) const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const lastAnchorIndicesRef = useRef<Record<string, number>>({}) const barsAreaRef = useRef<HTMLDivElement | null>(null)
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore() const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
@@ -152,79 +152,20 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
const { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => { const { executions, aggregateSegments, segmentMs } = useMemo(() => {
if (!stats) { if (!stats) {
return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 } return { executions: [], aggregateSegments: [], segmentMs: 0 }
} }
const workflowExecutions = stats.workflows.map(toWorkflowExecution)
return { return {
rawExecutions: stats.workflows.map(toWorkflowExecution), executions: workflowExecutions,
aggregateSegments: stats.aggregateSegments, aggregateSegments: stats.aggregateSegments,
segmentMs: stats.segmentMs, segmentMs: stats.segmentMs,
} }
}, [stats]) }, [stats])
/**
* Stabilize execution objects: reuse previous references for workflows
* whose segment data hasn't structurally changed between polls.
* This prevents cascading re-renders through WorkflowsList → StatusBar.
*/
const prevExecutionsRef = useRef<WorkflowExecution[]>([])
const executions = useMemo(() => {
const prevMap = new Map(prevExecutionsRef.current.map((e) => [e.workflowId, e]))
let anyChanged = false
const result = rawExecutions.map((exec) => {
const prev = prevMap.get(exec.workflowId)
if (!prev) {
anyChanged = true
return exec
}
if (
prev.overallSuccessRate !== exec.overallSuccessRate ||
prev.workflowName !== exec.workflowName ||
prev.segments.length !== exec.segments.length
) {
anyChanged = true
return exec
}
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = exec.segments[i]
if (
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp ||
ps.avgDurationMs !== ns.avgDurationMs ||
ps.p50Ms !== ns.p50Ms ||
ps.p90Ms !== ns.p90Ms ||
ps.p99Ms !== ns.p99Ms
) {
anyChanged = true
return exec
}
}
return prev
})
if (
!anyChanged &&
result.length === prevExecutionsRef.current.length &&
result.every((r, i) => r === prevExecutionsRef.current[i])
) {
return prevExecutionsRef.current
}
return result
}, [rawExecutions])
useEffect(() => {
prevExecutionsRef.current = executions
}, [executions])
const lastExecutionByWorkflow = useMemo(() => { const lastExecutionByWorkflow = useMemo(() => {
const map = new Map<string, number>() const map = new Map<string, number>()
for (const wf of executions) { for (const wf of executions) {
@@ -371,8 +312,6 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
[toggleWorkflowId] [toggleWorkflowId]
) )
lastAnchorIndicesRef.current = lastAnchorIndices
/** /**
* Handles segment click for selecting time segments. * Handles segment click for selecting time segments.
* @param workflowId - The workflow containing the segment * @param workflowId - The workflow containing the segment
@@ -422,7 +361,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
} else if (mode === 'range') { } else if (mode === 'range') {
setSelectedSegments((prev) => { setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || [] const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] = const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor] anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i) const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
@@ -431,12 +370,12 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
}) })
} }
}, },
[] [lastAnchorIndices]
) )
useEffect(() => { useEffect(() => {
setSelectedSegments((prev) => (Object.keys(prev).length > 0 ? {} : prev)) setSelectedSegments({})
setLastAnchorIndices((prev) => (Object.keys(prev).length > 0 ? {} : prev)) setLastAnchorIndices({})
}, [stats, timeRange, workflowIds, searchQuery]) }, [stats, timeRange, workflowIds, searchQuery])
if (isLoading) { if (isLoading) {
@@ -554,7 +493,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
</div> </div>
</div> </div>
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<WorkflowsList <WorkflowsList
filteredExecutions={filteredExecutions as WorkflowExecution[]} filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId} expandedWorkflowId={expandedWorkflowId}
@@ -568,5 +507,3 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
</div> </div>
) )
} }
export default memo(DashboardInner)

View File

@@ -43,199 +43,184 @@ import { useLogDetailsUIStore } from '@/stores/logs/store'
/** /**
* Workflow Output section with code viewer, copy, search, and context menu functionality * Workflow Output section with code viewer, copy, search, and context menu functionality
*/ */
const WorkflowOutputSection = memo( function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) { const contentRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null) const [copied, setCopied] = useState(false)
const [copied, setCopied] = useState(false)
const copyTimerRef = useRef<number | null>(null)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) // Context menu state
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const { const {
isSearchActive, isSearchActive,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
matchCount, matchCount,
currentMatchIndex, currentMatchIndex,
activateSearch, activateSearch,
closeSearch, closeSearch,
goToNextMatch, goToNextMatch,
goToPreviousMatch, goToPreviousMatch,
handleMatchCountChange, handleMatchCountChange,
searchInputRef, searchInputRef,
} = useCodeViewerFeatures({ contentRef }) } = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => { const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY }) setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true) setIsContextMenuOpen(true)
}, []) }, [])
const closeContextMenu = useCallback(() => { const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false) setIsContextMenuOpen(false)
}, []) }, [])
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString) navigator.clipboard.writeText(jsonString)
setCopied(true) setCopied(true)
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) setTimeout(() => setCopied(false), 1500)
copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500) closeContextMenu()
closeContextMenu() }, [jsonString, closeContextMenu])
}, [jsonString, closeContextMenu])
useEffect(() => { const handleSearch = useCallback(() => {
return () => { activateSearch()
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) closeContextMenu()
} }, [activateSearch, closeContextMenu])
}, [])
const handleSearch = useCallback(() => { return (
activateSearch() <div className='relative flex min-w-0 flex-col overflow-hidden'>
closeContextMenu() <div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
}, [activateSearch, closeContextMenu]) <Code.Viewer
code={jsonString}
return ( language='json'
<div className='relative flex min-w-0 flex-col overflow-hidden'> className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'> wrapText
<Code.Viewer searchQuery={isSearchActive ? searchQuery : undefined}
code={jsonString} currentMatchIndex={currentMatchIndex}
language='json' onMatchCountChange={handleMatchCountChange}
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]' />
wrapText {/* Glass action buttons overlay */}
searchQuery={isSearchActive ? searchQuery : undefined} {!isSearchActive && (
currentMatchIndex={currentMatchIndex} <div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
onMatchCountChange={handleMatchCountChange} <Tooltip.Root>
/> <Tooltip.Trigger asChild>
{/* Glass action buttons overlay */} <Button
{!isSearchActive && ( type='button'
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'> variant='default'
<Tooltip.Root> onClick={(e) => {
<Tooltip.Trigger asChild> e.stopPropagation()
<Button handleCopy()
type='button' }}
variant='default' className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
onClick={(e) => { >
e.stopPropagation() {copied ? (
handleCopy() <Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
}} ) : (
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]' <Clipboard className='h-[10px] w-[10px]' />
> )}
{copied ? ( </Button>
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' /> </Tooltip.Trigger>
) : ( <Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
<Clipboard className='h-[10px] w-[10px]' /> </Tooltip.Root>
)} <Tooltip.Root>
</Button> <Tooltip.Trigger asChild>
</Tooltip.Trigger> <Button
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content> type='button'
</Tooltip.Root> variant='default'
<Tooltip.Root> onClick={(e) => {
<Tooltip.Trigger asChild> e.stopPropagation()
<Button activateSearch()
type='button' }}
variant='default' className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
onClick={(e) => { >
e.stopPropagation() <Search className='h-[10px] w-[10px]' />
activateSearch() </Button>
}} </Tooltip.Trigger>
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]' <Tooltip.Content side='top'>Search</Tooltip.Content>
> </Tooltip.Root>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={closeSearch}
aria-label='Close search'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div> </div>
)} )}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div> </div>
)
}, {/* Search Overlay */}
(prev, next) => prev.output === next.output {isSearchActive && (
) <div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
interface LogDetailsProps { interface LogDetailsProps {
/** The log to display details for */ /** The log to display details for */
@@ -293,6 +278,7 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog]) }, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
const workflowOutput = useMemo(() => { const workflowOutput = useMemo(() => {
const executionData = log?.executionData as const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> } | { finalOutput?: Record<string, unknown> }

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { memo } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -30,7 +29,7 @@ interface LogRowContextMenuProps {
* Context menu for log rows. * Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering. * Provides quick actions for copying data, navigation, and filtering.
*/ */
export const LogRowContextMenu = memo(function LogRowContextMenu({ export function LogRowContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -122,4 +121,4 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
}) }

View File

@@ -24,7 +24,6 @@ interface LogRowProps {
log: WorkflowLog log: WorkflowLog
isSelected: boolean isSelected: boolean
onClick: (log: WorkflowLog) => void onClick: (log: WorkflowLog) => void
onHover?: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
} }
@@ -34,14 +33,7 @@ interface LogRowProps {
* Uses shallow comparison for the log object. * Uses shallow comparison for the log object.
*/ */
const LogRow = memo( const LogRow = memo(
function LogRow({ function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
log,
isSelected,
onClick,
onHover,
onContextMenu,
selectedRowRef,
}: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow const workflowName = isDeletedWorkflow
@@ -51,8 +43,6 @@ const LogRow = memo(
const handleClick = useCallback(() => onClick(log), [onClick, log]) const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleMouseEnter = useCallback(() => onHover?.(log), [onHover, log])
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (onContextMenu) { if (onContextMenu) {
@@ -71,7 +61,6 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]' isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)} )}
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className='flex flex-1 items-center'> <div className='flex flex-1 items-center'>
@@ -153,8 +142,7 @@ const LogRow = memo(
prevProps.log.id === nextProps.log.id && prevProps.log.id === nextProps.log.id &&
prevProps.log.duration === nextProps.log.duration && prevProps.log.duration === nextProps.log.duration &&
prevProps.log.status === nextProps.log.status && prevProps.log.status === nextProps.log.status &&
prevProps.isSelected === nextProps.isSelected && prevProps.isSelected === nextProps.isSelected
prevProps.onHover === nextProps.onHover
) )
} }
) )
@@ -163,7 +151,6 @@ interface RowProps {
logs: WorkflowLog[] logs: WorkflowLog[]
selectedLogId: string | null selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean isFetchingNextPage: boolean
@@ -180,7 +167,6 @@ function Row({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,
@@ -212,7 +198,6 @@ function Row({
log={log} log={log}
isSelected={isSelected} isSelected={isSelected}
onClick={onLogClick} onClick={onLogClick}
onHover={onLogHover}
onContextMenu={onLogContextMenu} onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null} selectedRowRef={isSelected ? selectedRowRef : null}
/> />
@@ -224,7 +209,6 @@ export interface LogsListProps {
logs: WorkflowLog[] logs: WorkflowLog[]
selectedLogId: string | null selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean hasNextPage: boolean
@@ -243,7 +227,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
hasNextPage, hasNextPage,
@@ -289,7 +272,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,
@@ -299,7 +281,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { import {
@@ -113,7 +113,7 @@ function formatAlertConfigLabel(config: {
} }
} }
export const NotificationSettings = memo(function NotificationSettings({ export function NotificationSettings({
workspaceId, workspaceId,
open, open,
onOpenChange, onOpenChange,
@@ -144,7 +144,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: '', slackChannelId: '',
slackChannelName: '', slackChannelName: '',
slackAccountId: '', slackAccountId: '',
useAlertRule: false,
alertRule: 'none' as AlertRule, alertRule: 'none' as AlertRule,
consecutiveFailures: 3, consecutiveFailures: 3,
failureRatePercent: 50, failureRatePercent: 50,
@@ -212,7 +212,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: '', slackChannelId: '',
slackChannelName: '', slackChannelName: '',
slackAccountId: '', slackAccountId: '',
useAlertRule: false,
alertRule: 'none', alertRule: 'none',
consecutiveFailures: 3, consecutiveFailures: 3,
failureRatePercent: 50, failureRatePercent: 50,
@@ -484,6 +484,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: subscription.slackConfig?.channelId || '', slackChannelId: subscription.slackConfig?.channelId || '',
slackChannelName: subscription.slackConfig?.channelName || '', slackChannelName: subscription.slackConfig?.channelName || '',
slackAccountId: subscription.slackConfig?.accountId || '', slackAccountId: subscription.slackConfig?.accountId || '',
useAlertRule: !!subscription.alertConfig,
alertRule: subscription.alertConfig?.rule || 'none', alertRule: subscription.alertConfig?.rule || 'none',
consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3,
failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, failureRatePercent: subscription.alertConfig?.failureRatePercent || 50,
@@ -1288,4 +1289,4 @@ export const NotificationSettings = memo(function NotificationSettings({
</Modal> </Modal>
</> </>
) )
}) }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
@@ -149,7 +149,7 @@ function getTriggerIcon(
* @param props - The component props * @param props - The component props
* @returns The complete logs toolbar * @returns The complete logs toolbar
*/ */
export const LogsToolbar = memo(function LogsToolbar({ export function LogsToolbar({
viewMode, viewMode,
onViewModeChange, onViewModeChange,
isRefreshing, isRefreshing,
@@ -749,4 +749,4 @@ export const LogsToolbar = memo(function LogsToolbar({
</div> </div>
</div> </div>
) )
}) }

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
@@ -11,17 +10,12 @@ import {
hasActiveFilters, hasActiveFilters,
} from '@/lib/logs/filters' } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useFolders } from '@/hooks/queries/folders' import { useFolders } from '@/hooks/queries/folders'
import { import { useDashboardStats, useLogDetail, useLogsList } from '@/hooks/queries/logs'
prefetchLogDetail,
useDashboardStats,
useLogDetail,
useLogsList,
} from '@/hooks/queries/logs'
import { useDebounce } from '@/hooks/use-debounce' import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store' import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types' import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { import {
Dashboard, Dashboard,
ExecutionSnapshot, ExecutionSnapshot,
@@ -36,38 +30,6 @@ import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const
interface LogSelectionState {
selectedLogId: string | null
isSidebarOpen: boolean
}
type LogSelectionAction =
| { type: 'TOGGLE_LOG'; logId: string }
| { type: 'SELECT_LOG'; logId: string }
| { type: 'CLOSE_SIDEBAR' }
| { type: 'TOGGLE_SIDEBAR' }
function logSelectionReducer(
state: LogSelectionState,
action: LogSelectionAction
): LogSelectionState {
switch (action.type) {
case 'TOGGLE_LOG':
if (state.selectedLogId === action.logId && state.isSidebarOpen) {
return { selectedLogId: null, isSidebarOpen: false }
}
return { selectedLogId: action.logId, isSidebarOpen: true }
case 'SELECT_LOG':
return { ...state, selectedLogId: action.logId }
case 'CLOSE_SIDEBAR':
return { selectedLogId: null, isSidebarOpen: false }
case 'TOGGLE_SIDEBAR':
return state.selectedLogId ? { ...state, isSidebarOpen: !state.isSidebarOpen } : state
default:
return state
}
}
/** /**
* Logs page component displaying workflow execution history. * Logs page component displaying workflow execution history.
* Supports filtering, search, live updates, and detailed log inspection. * Supports filtering, search, live updates, and detailed log inspection.
@@ -98,13 +60,11 @@ export default function Logs() {
setWorkspaceId(workspaceId) setWorkspaceId(workspaceId)
}, [workspaceId, setWorkspaceId]) }, [workspaceId, setWorkspaceId])
const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
selectedLogId: null, const [isSidebarOpen, setIsSidebarOpen] = useState(false)
isSidebarOpen: false,
})
const selectedRowRef = useRef<HTMLTableRowElement | null>(null) const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null) const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isInitialized = useRef<boolean>(false) const isInitialized = useRef<boolean>(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -122,13 +82,6 @@ export default function Logs() {
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const isSearchOpenRef = useRef<boolean>(false) const isSearchOpenRef = useRef<boolean>(false)
const refreshTimersRef = useRef(new Set<number>())
const logsRef = useRef<WorkflowLog[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
@@ -141,19 +94,8 @@ export default function Logs() {
const [previewLogId, setPreviewLogId] = useState<string | null>(null) const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const queryClient = useQueryClient()
const detailRefetchInterval = useCallback(
(query: { state: { data?: WorkflowLog } }) => {
if (!isLive) return false
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 3000 : false
},
[isLive]
)
const activeLogQuery = useLogDetail(activeLogId ?? undefined, { const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
refetchInterval: detailRefetchInterval, refetchInterval: isLive ? 3000 : false,
}) })
const logFilters = useMemo( const logFilters = useMemo(
@@ -212,73 +154,42 @@ export default function Logs() {
return { ...selectedLogFromList, ...activeLogQuery.data } return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen]) }, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
const handleLogHover = useCallback(
(log: WorkflowLog) => {
prefetchLogDetail(queryClient, log.id)
},
[queryClient]
)
useFolders(workspaceId) useFolders(workspaceId)
useEffect(() => {
logsRef.current = logs
}, [logs])
useEffect(() => {
selectedLogIndexRef.current = selectedLogIndex
}, [selectedLogIndex])
useEffect(() => {
selectedLogIdRef.current = selectedLogId
}, [selectedLogId])
useEffect(() => {
logsRefetchRef.current = logsQuery.refetch
}, [logsQuery.refetch])
useEffect(() => {
activeLogRefetchRef.current = activeLogQuery.refetch
}, [activeLogQuery.refetch])
useEffect(() => {
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
}
}, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage])
useEffect(() => {
const timers = refreshTimersRef.current
return () => {
timers.forEach((id) => window.clearTimeout(id))
timers.clear()
}
}, [])
useEffect(() => { useEffect(() => {
if (isInitialized.current) { if (isInitialized.current) {
setStoreSearchQuery(debouncedSearchQuery) setStoreSearchQuery(debouncedSearchQuery)
} }
}, [debouncedSearchQuery, setStoreSearchQuery]) }, [debouncedSearchQuery, setStoreSearchQuery])
const handleLogClick = useCallback((log: WorkflowLog) => { const handleLogClick = useCallback(
dispatch({ type: 'TOGGLE_LOG', logId: log.id }) (log: WorkflowLog) => {
}, []) if (selectedLogId === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLogId(null)
return
}
setSelectedLogId(log.id)
setIsSidebarOpen(true)
},
[selectedLogId, isSidebarOpen]
)
const handleNavigateNext = useCallback(() => { const handleNavigateNext = useCallback(() => {
const idx = selectedLogIndexRef.current if (selectedLogIndex < logs.length - 1) {
const currentLogs = logsRef.current setSelectedLogId(logs[selectedLogIndex + 1].id)
if (idx < currentLogs.length - 1) {
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
} }
}, []) }, [selectedLogIndex, logs])
const handleNavigatePrev = useCallback(() => { const handleNavigatePrev = useCallback(() => {
const idx = selectedLogIndexRef.current if (selectedLogIndex > 0) {
if (idx > 0) { setSelectedLogId(logs[selectedLogIndex - 1].id)
dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id })
} }
}, []) }, [selectedLogIndex, logs])
const handleCloseSidebar = useCallback(() => { const handleCloseSidebar = useCallback(() => {
dispatch({ type: 'CLOSE_SIDEBAR' }) setIsSidebarOpen(false)
setSelectedLogId(null)
}, []) }, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => { const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
@@ -349,34 +260,26 @@ export default function Logs() {
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
setIsVisuallyRefreshing(false) logsQuery.refetch()
refreshTimersRef.current.delete(timerId) if (selectedLogId) {
}, REFRESH_SPINNER_DURATION_MS) activeLogQuery.refetch()
refreshTimersRef.current.add(timerId)
logsRefetchRef.current()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
} }
}, []) }, [logsQuery, activeLogQuery, selectedLogId])
const handleToggleLive = useCallback(() => { const handleToggleLive = useCallback(() => {
setIsLive((prev) => { const newIsLive = !isLive
if (!prev) { setIsLive(newIsLive)
setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { if (newIsLive) {
setIsVisuallyRefreshing(false) setIsVisuallyRefreshing(true)
refreshTimersRef.current.delete(timerId) setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
}, REFRESH_SPINNER_DURATION_MS) logsQuery.refetch()
refreshTimersRef.current.add(timerId) if (selectedLogId) {
logsRefetchRef.current() activeLogQuery.refetch()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
}
} }
return !prev }
}) }, [isLive, logsQuery, activeLogQuery, selectedLogId])
}, [])
const prevIsFetchingRef = useRef(logsQuery.isFetching) const prevIsFetchingRef = useRef(logsQuery.isFetching)
useEffect(() => { useEffect(() => {
@@ -386,15 +289,11 @@ export default function Logs() {
if (isLive && !wasFetching && isFetching) { if (isLive && !wasFetching && isFetching) {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
} }
}, [logsQuery.isFetching, isLive]) }, [logsQuery.isFetching, isLive])
const handleExport = useCallback(async () => { const handleExport = async () => {
setIsExporting(true) setIsExporting(true)
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -428,17 +327,7 @@ export default function Logs() {
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
}, [ }
workspaceId,
level,
triggers,
workflowIds,
folderIds,
timeRange,
startDate,
endDate,
debouncedSearchQuery,
])
useEffect(() => { useEffect(() => {
if (!isInitialized.current) { if (!isInitialized.current) {
@@ -459,59 +348,41 @@ export default function Logs() {
}, [initializeFromURL]) }, [initializeFromURL])
const loadMoreLogs = useCallback(() => { const loadMoreLogs = useCallback(() => {
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!logsQuery.isFetching && logsQuery.hasNextPage) {
if (!isFetching && hasNextPage) { logsQuery.fetchNextPage()
fetchNextPage()
} }
}, []) }, [logsQuery])
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return if (isSearchOpenRef.current) return
const currentLogs = logsRef.current if (logs.length === 0) return
const currentIndex = selectedLogIndexRef.current
if (currentLogs.length === 0) return
if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault() e.preventDefault()
dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) setSelectedLogId(logs[0].id)
return return
} }
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) { if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
e.preventDefault() e.preventDefault()
handleNavigatePrev() handleNavigatePrev()
} }
if ( if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && selectedLogIndex < logs.length - 1) {
e.key === 'ArrowDown' &&
!e.metaKey &&
!e.ctrlKey &&
currentIndex < currentLogs.length - 1
) {
e.preventDefault() e.preventDefault()
handleNavigateNext() handleNavigateNext()
} }
if (e.key === 'Enter' && selectedLogIdRef.current) { if (e.key === 'Enter' && selectedLogId) {
e.preventDefault() e.preventDefault()
dispatch({ type: 'TOGGLE_SIDEBAR' }) setIsSidebarOpen(!isSidebarOpen)
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleNavigateNext, handleNavigatePrev]) }, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), [])
const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), [])
const handleSearchOpenChange = useCallback((open: boolean) => {
isSearchOpenRef.current = open
}, [])
const handleClosePreview = useCallback(() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}, [])
const isDashboardView = viewMode === 'dashboard' const isDashboardView = viewMode === 'dashboard'
@@ -531,10 +402,12 @@ export default function Logs() {
onExport={handleExport} onExport={handleExport}
canEdit={userPermissions.canEdit} canEdit={userPermissions.canEdit}
hasLogs={logs.length > 0} hasLogs={logs.length > 0}
onOpenNotificationSettings={handleOpenNotificationSettings} onOpenNotificationSettings={() => setIsNotificationSettingsOpen(true)}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
onSearchOpenChange={handleSearchOpenChange} onSearchOpenChange={(open: boolean) => {
isSearchOpenRef.current = open
}}
/> />
</div> </div>
@@ -576,7 +449,7 @@ export default function Logs() {
</div> </div>
{/* Table body - virtualized */} {/* Table body - virtualized */}
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
{logsQuery.isLoading && !logsQuery.data ? ( {logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'> <div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
@@ -603,7 +476,6 @@ export default function Logs() {
logs={logs} logs={logs}
selectedLogId={selectedLogId} selectedLogId={selectedLogId}
onLogClick={handleLogClick} onLogClick={handleLogClick}
onLogHover={handleLogHover}
onLogContextMenu={handleLogContextMenu} onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef} selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false} hasNextPage={logsQuery.hasNextPage ?? false}
@@ -639,7 +511,7 @@ export default function Logs() {
isOpen={contextMenuOpen} isOpen={contextMenuOpen}
position={contextMenuPosition} position={contextMenuPosition}
menuRef={contextMenuRef} menuRef={contextMenuRef}
onClose={handleCloseContextMenu} onClose={() => setContextMenuOpen(false)}
log={contextMenuLog} log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId} onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow} onOpenWorkflow={handleOpenWorkflow}
@@ -656,7 +528,10 @@ export default function Logs() {
traceSpans={activeLogQuery.data.executionData?.traceSpans} traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal isModal
isOpen={isPreviewOpen} isOpen={isPreviewOpen}
onClose={handleClosePreview} onClose={() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}}
/> />
)} )}
</div> </div>

View File

@@ -239,12 +239,7 @@ export const ComboBox = memo(function ComboBox({
*/ */
const defaultOptionValue = useMemo(() => { const defaultOptionValue = useMemo(() => {
if (defaultValue !== undefined) { if (defaultValue !== undefined) {
// Validate that the default value exists in the available (filtered) options return defaultValue
const defaultInOptions = evaluatedOptions.find((opt) => getOptionValue(opt) === defaultValue)
if (defaultInOptions) {
return defaultValue
}
// Default not available (e.g. provider disabled) — fall through to other fallbacks
} }
// For model field, default to claude-sonnet-4-5 if available // For model field, default to claude-sonnet-4-5 if available

View File

@@ -30,6 +30,7 @@ export interface OAuthRequiredModalProps {
requiredScopes?: string[] requiredScopes?: string[]
serviceId: string serviceId: string
newScopes?: string[] newScopes?: string[]
onConnect?: () => Promise<void> | void
} }
const SCOPE_DESCRIPTIONS: Record<string, string> = { const SCOPE_DESCRIPTIONS: Record<string, string> = {
@@ -314,6 +315,7 @@ export function OAuthRequiredModal({
requiredScopes = [], requiredScopes = [],
serviceId, serviceId,
newScopes = [], newScopes = [],
onConnect,
}: OAuthRequiredModalProps) { }: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider) const { baseProvider } = parseProvider(provider)
@@ -359,6 +361,12 @@ export function OAuthRequiredModal({
setError(null) setError(null)
try { try {
if (onConnect) {
await onConnect()
onClose()
return
}
const providerId = getProviderIdFromServiceId(serviceId) const providerId = getProviderIdFromServiceId(serviceId)
logger.info('Linking OAuth2:', { logger.info('Linking OAuth2:', {

View File

@@ -1,12 +1,13 @@
'use client' 'use client'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react' import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client' import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
getCanonicalScopesForProvider, getCanonicalScopesForProvider,
getProviderIdFromServiceId, getProviderIdFromServiceId,
@@ -18,15 +19,14 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants' import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
interface CredentialSelectorProps { interface CredentialSelectorProps {
@@ -46,6 +46,8 @@ export function CredentialSelector({
previewValue, previewValue,
previewContextValues, previewContextValues,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('') const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@@ -96,64 +98,64 @@ export function CredentialSelector({
data: credentials = [], data: credentials = [],
isFetching: credentialsLoading, isFetching: credentialsLoading,
refetch: refetchCredentials, refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) } = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo( const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId), () => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId] [credentials, selectedId]
) )
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const selectedCredentialSet = useMemo( const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId), () => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId] [credentialSets, selectedCredentialSetId]
) )
const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet) const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
if (data.credential.id !== selectedId) {
setStoreValue(data.credential.id)
}
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => { const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name if (selectedCredential) return selectedCredential.name
if (isForeign) return CREDENTIAL.FOREIGN_LABEL if (inaccessibleCredentialName) return inaccessibleCredentialName
return '' return ''
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign]) }, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName])
const displayValue = isEditing ? editingValue : resolvedLabel const displayValue = isEditing ? editingValue : resolvedLabel
const invalidSelection = useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -195,8 +197,18 @@ export function CredentialSelector({
) )
const handleAddCredential = useCallback(() => { const handleAddCredential = useCallback(() => {
setShowOAuthModal(true) writePendingCredentialCreateRequest({
}, []) workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const getProviderIcon = useCallback((providerName: OAuthProvider) => { const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName) const { baseProvider } = parseProvider(providerName)
@@ -251,23 +263,18 @@ export function CredentialSelector({
label: cred.name, label: cred.name,
value: cred.id, value: cred.id,
})) }))
credentialItems.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
if (credentialItems.length > 0) { groups.push({
groups.push({ section: 'Personal Credential',
section: 'Personal Credential', items: credentialItems,
items: credentialItems, })
})
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
return { comboboxOptions: [], comboboxGroups: groups } return { comboboxOptions: [], comboboxGroups: groups }
} }
@@ -277,12 +284,13 @@ export function CredentialSelector({
value: cred.id, value: cred.id,
})) }))
if (credentials.length === 0) { options.push({
options.push({ label:
label: `Connect ${getProviderName(provider)} account`, credentials.length > 0
value: '__connect_account__', ? `Connect another ${getProviderName(provider)} account`
}) : `Connect ${getProviderName(provider)} account`,
} value: '__connect_account__',
})
return { comboboxOptions: options, comboboxGroups: undefined } return { comboboxOptions: options, comboboxGroups: undefined }
}, [ }, [
@@ -368,7 +376,7 @@ export function CredentialSelector({
} }
disabled={effectiveDisabled} disabled={effectiveDisabled}
editable={true} editable={true}
filterOptions={!isForeign && !isForeignCredentialSet} filterOptions={true}
isLoading={credentialsLoading} isLoading={credentialsLoading}
overlayContent={overlayContent} overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''} className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
@@ -380,15 +388,13 @@ export function CredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' /> <span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required Additional permissions required
</div> </div>
{!isForeign && ( <Button
<Button variant='active'
variant='active' onClick={() => setShowOAuthModal(true)}
onClick={() => setShowOAuthModal(true)} className='w-full px-[8px] py-[4px] font-medium text-[12px]'
className='w-full px-[8px] py-[4px] font-medium text-[12px]' >
> Update access
Update access </Button>
</Button>
)}
</div> </div>
)} )}
@@ -407,7 +413,11 @@ export function CredentialSelector({
) )
} }
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) { function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
providerId: string,
workspaceId: string
) {
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
void refetchCredentials() void refetchCredentials()
@@ -425,12 +435,29 @@ function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>
} }
} }
const handleCredentialsUpdated = (
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
) => {
if (event.detail?.providerId && event.detail.providerId !== providerId) {
return
}
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
return
}
refresh()
}
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener(
'oauth-credentials-updated',
handleCredentialsUpdated as EventListener
)
} }
}, [refetchCredentials]) }, [providerId, workspaceId, refetchCredentials])
} }

View File

@@ -9,6 +9,7 @@ import {
PopoverSection, PopoverSection,
} from '@/components/emcn' } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
usePersonalEnvironment, usePersonalEnvironment,
useWorkspaceEnvironment, useWorkspaceEnvironment,
@@ -168,7 +169,15 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}, [searchTerm]) }, [searchTerm])
const openEnvironmentSettings = () => { const openEnvironmentSettings = () => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } })) if (workspaceId) {
writePendingCredentialCreateRequest({
workspaceId,
type: 'env_personal',
envKey: searchTerm.trim(),
requestedAt: Date.now(),
})
}
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
onClose?.() onClose?.()
} }
@@ -302,7 +311,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}} }}
> >
<Plus className='h-3 w-3' /> <Plus className='h-3 w-3' />
<span>Create environment variable</span> <span>Create Secret</span>
</PopoverItem> </PopoverItem>
</PopoverScrollArea> </PopoverScrollArea>
) : ( ) : (

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -125,8 +124,6 @@ export function FileSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => { const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, { return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl, workflowId: workflowIdFromUrl,
@@ -168,7 +165,6 @@ export function FileSelectorInput({
const disabledReason = const disabledReason =
finalDisabled || finalDisabled ||
isForeignCredential ||
missingCredential || missingCredential ||
missingDomain || missingDomain ||
missingProject || missingProject ||

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { getProviderIdFromServiceId } from '@/lib/oauth' import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -47,10 +46,6 @@ export function FolderSelectorInput({
subBlock.canonicalParamId === 'copyDestinationId' || subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' || subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder' subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
// Central dependsOn gating // Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
@@ -119,9 +114,7 @@ export function FolderSelectorInput({
selectorContext={ selectorContext={
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' } selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
} }
disabled={ disabled={finalDisabled || missingCredential || !selectorResolution?.key}
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select folder'} placeholder={subBlock.placeholder || 'Select folder'}

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -73,11 +72,6 @@ export function ProjectSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled, disabled,
@@ -123,7 +117,7 @@ export function ProjectSelectorInput({
subBlock={subBlock} subBlock={subBlock}
selectorKey={selectorResolution.key} selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context} selectorContext={selectorResolution.context}
disabled={finalDisabled || isForeignCredential || missingCredential} disabled={finalDisabled || missingCredential}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select project'} placeholder={subBlock.placeholder || 'Select project'}

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -87,8 +86,6 @@ export function SheetSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => { const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, { return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl, workflowId: workflowIdFromUrl,
@@ -101,11 +98,7 @@ export function SheetSelectorInput({
const missingSpreadsheet = !normalizedSpreadsheetId const missingSpreadsheet = !normalizedSpreadsheetId
const disabledReason = const disabledReason =
finalDisabled || finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key
isForeignCredential ||
missingCredential ||
missingSpreadsheet ||
!selectorResolution?.key
if (!selectorResolution?.key) { if (!selectorResolution?.key) {
return ( return (

View File

@@ -6,7 +6,6 @@ import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth' import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -85,11 +84,6 @@ export function SlackSelectorInput({
? (effectiveBotToken as string) || '' ? (effectiveBotToken as string) || ''
: (effectiveCredential as string) || '' : (effectiveCredential as string) || ''
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || ''
)
useEffect(() => { useEffect(() => {
const val = isPreview && previewValue !== undefined ? previewValue : storeValue const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (typeof val === 'string') { if (typeof val === 'string') {
@@ -99,7 +93,7 @@ export function SlackSelectorInput({
const requiresCredential = dependsOn.includes('credential') const requiresCredential = dependsOn.includes('credential')
const missingCredential = !credential || credential.trim().length === 0 const missingCredential = !credential || credential.trim().length === 0
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential) const shouldForceDisable = requiresCredential && missingCredential
const context: SelectorContext = useMemo( const context: SelectorContext = useMemo(
() => ({ () => ({
@@ -136,7 +130,7 @@ export function SlackSelectorInput({
subBlock={subBlock} subBlock={subBlock}
selectorKey={config.selectorKey} selectorKey={config.selectorKey}
selectorContext={context} selectorContext={context}
disabled={finalDisabled || shouldForceDisable || isForeignCredential} disabled={finalDisabled || shouldForceDisable}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || config.placeholder} placeholder={subBlock.placeholder || config.placeholder}

View File

@@ -1,6 +1,8 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
getCanonicalScopesForProvider, getCanonicalScopesForProvider,
getProviderIdFromServiceId, getProviderIdFromServiceId,
@@ -11,8 +13,7 @@ import {
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CREDENTIAL } from '@/executor/constants' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -64,6 +65,8 @@ export function ToolCredentialSelector({
serviceId, serviceId,
disabled = false, disabled = false,
}: ToolCredentialSelectorProps) { }: ToolCredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('') const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@@ -78,50 +81,58 @@ export function ToolCredentialSelector({
data: credentials = [], data: credentials = [],
isFetching: credentialsLoading, isFetching: credentialsLoading,
refetch: refetchCredentials, refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) } = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo( const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId), () => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId] [credentials, selectedId]
) )
const shouldFetchForeignMeta = const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } = useEffect(() => {
useOAuthCredentialDetail( if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
shouldFetchForeignMeta ? selectedId : undefined, setInaccessibleCredentialName(null)
activeWorkflowId || undefined, return
shouldFetchForeignMeta }
)
const hasForeignMeta = foreignCredentials.length > 0 let cancelled = false
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) ;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
if (data.credential.id !== selectedId) {
onChange(data.credential.id)
}
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => { const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name if (selectedCredential) return selectedCredential.name
if (isForeign) return CREDENTIAL.FOREIGN_LABEL if (inaccessibleCredentialName) return inaccessibleCredentialName
return '' return ''
}, [selectedCredential, isForeign]) }, [selectedCredential, inaccessibleCredentialName])
const inputValue = isEditing ? editingInputValue : resolvedLabel const inputValue = isEditing ? editingInputValue : resolvedLabel
const invalidSelection = useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
onChange('')
}, [invalidSelection, onChange])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -149,8 +160,18 @@ export function ToolCredentialSelector({
) )
const handleAddCredential = useCallback(() => { const handleAddCredential = useCallback(() => {
setShowOAuthModal(true) writePendingCredentialCreateRequest({
}, []) workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const comboboxOptions = useMemo(() => { const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({ const options = credentials.map((cred) => ({
@@ -158,12 +179,13 @@ export function ToolCredentialSelector({
value: cred.id, value: cred.id,
})) }))
if (credentials.length === 0) { options.push({
options.push({ label:
label: `Connect ${getProviderName(provider)} account`, credentials.length > 0
value: '__connect_account__', ? `Connect another ${getProviderName(provider)} account`
}) : `Connect ${getProviderName(provider)} account`,
} value: '__connect_account__',
})
return options return options
}, [credentials, provider]) }, [credentials, provider])
@@ -213,7 +235,7 @@ export function ToolCredentialSelector({
placeholder={effectiveLabel} placeholder={effectiveLabel}
disabled={disabled} disabled={disabled}
editable={true} editable={true}
filterOptions={!isForeign} filterOptions={true}
isLoading={credentialsLoading} isLoading={credentialsLoading}
overlayContent={overlayContent} overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''} className={selectedId ? 'pl-[28px]' : ''}
@@ -225,15 +247,13 @@ export function ToolCredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' /> <span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required Additional permissions required
</div> </div>
{!isForeign && ( <Button
<Button variant='active'
variant='active' onClick={() => setShowOAuthModal(true)}
onClick={() => setShowOAuthModal(true)} className='w-full px-[8px] py-[4px] font-medium text-[12px]'
className='w-full px-[8px] py-[4px] font-medium text-[12px]' >
> Update access
Update access </Button>
</Button>
)}
</div> </div>
)} )}
@@ -252,7 +272,11 @@ export function ToolCredentialSelector({
) )
} }
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) { function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
providerId: string,
workspaceId: string
) {
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
void refetchCredentials() void refetchCredentials()
@@ -270,12 +294,29 @@ function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>
} }
} }
const handleCredentialsUpdated = (
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
) => {
if (event.detail?.providerId && event.detail.providerId !== providerId) {
return
}
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
return
}
refresh()
}
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener(
'oauth-credentials-updated',
handleCredentialsUpdated as EventListener
)
} }
}, [refetchCredentials]) }, [providerId, workspaceId, refetchCredentials])
} }

View File

@@ -1,50 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
export function useForeignCredential(
provider: string | undefined,
credentialId: string | undefined
) {
const [isForeign, setIsForeign] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
useEffect(() => {
let cancelled = false
async function check() {
setLoading(true)
setError(null)
try {
if (!normalizedProvider || !normalizedCredentialId) {
if (!cancelled) setIsForeign(false)
return
}
const res = await fetch(
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
)
if (!res.ok) {
if (!cancelled) setIsForeign(true)
return
}
const data = await res.json()
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
if (!cancelled) setIsForeign(!isOwn)
} catch (e) {
if (!cancelled) {
setIsForeign(true)
setError((e as Error).message)
}
} finally {
if (!cancelled) setLoading(false)
}
}
void check()
return () => {
cancelled = true
}
}, [normalizedProvider, normalizedCredentialId])
return { isForeignCredential: isForeign, loading, error }
}

View File

@@ -255,6 +255,69 @@ const WorkflowContent = React.memo(() => {
const addNotification = useNotificationStore((state) => state.addNotification) const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
const pending = window.sessionStorage.getItem(OAUTH_CONNECT_PENDING_KEY)
if (!pending) return
window.sessionStorage.removeItem(OAUTH_CONNECT_PENDING_KEY)
;(async () => {
try {
const {
displayName,
providerId,
preCount,
workspaceId: wsId,
reconnect,
} = JSON.parse(pending) as {
displayName: string
providerId: string
preCount: number
workspaceId: string
reconnect?: boolean
}
if (reconnect) {
addNotification({
level: 'info',
message: `"${displayName}" reconnected successfully.`,
})
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId, workspaceId: wsId },
})
)
return
}
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(wsId)}&type=oauth`
)
const data = response.ok ? await response.json() : { credentials: [] }
const oauthCredentials = (data.credentials ?? []) as Array<{
displayName: string
providerId: string | null
}>
if (oauthCredentials.length > preCount) {
addNotification({
level: 'info',
message: `"${displayName}" credential connected successfully.`,
})
} else {
const existing = oauthCredentials.find((c) => c.providerId === providerId)
const existingName = existing?.displayName || displayName
addNotification({
level: 'info',
message: `This account is already connected as "${existingName}".`,
})
}
} catch {
// Ignore malformed sessionStorage data
}
})()
}, [])
const { const {
workflows, workflows,
activeWorkflowId, activeWorkflowId,

View File

@@ -473,7 +473,7 @@ function ConnectionsSection({
</div> </div>
)} )}
{/* Environment Variables */} {/* Secrets */}
{envVars.length > 0 && ( {envVars.length > 0 && (
<div className='mb-[2px] last:mb-0'> <div className='mb-[2px] last:mb-0'>
<div <div
@@ -489,7 +489,7 @@ function ConnectionsSection({
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]' 'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)} )}
> >
Environment Variables Secrets
</span> </span>
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(

View File

@@ -223,12 +223,7 @@ function resolveToolsDisplay(
* - Resolves tool names from block registry * - Resolves tool names from block registry
* - Shows '-' for other selector types that need hydration * - Shows '-' for other selector types that need hydration
*/ */
const SubBlockRow = memo(function SubBlockRow({ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
title,
value,
subBlock,
rawValue,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -260,7 +255,7 @@ const SubBlockRow = memo(function SubBlockRow({
)} )}
</div> </div>
) )
}) }
/** /**
* Preview block component for workflow visualization. * Preview block component for workflow visualization.

View File

@@ -31,10 +31,9 @@ const logger = createLogger('ApiKeys')
interface ApiKeysProps { interface ApiKeysProps {
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
} }
export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { export function ApiKeys({ onOpenChange }: ApiKeysProps) {
const { data: session } = useSession() const { data: session } = useSession()
const userId = session?.user?.id const userId = session?.user?.id
const params = useParams() const params = useParams()
@@ -118,12 +117,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
onOpenChange?.(open) onOpenChange?.(open)
} }
useEffect(() => {
if (registerCloseHandler) {
registerCloseHandler(handleModalClose)
}
}, [registerCloseHandler])
useEffect(() => { useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) { if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ scrollContainerRef.current.scrollTo({

View File

@@ -0,0 +1,15 @@
'use client'
import { CredentialsManager } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager'
interface CredentialsProps {
onOpenChange?: (open: boolean) => void
}
export function Credentials(_props: CredentialsProps) {
return (
<div className='h-full min-h-0'>
<CredentialsManager />
</div>
)
}

View File

@@ -1,864 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search, Share2, Undo2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Input as EmcnInput,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Input, Skeleton } from '@/components/ui'
import { isValidEnvVarName } from '@/executor/constants'
import {
usePersonalEnvironment,
useRemoveWorkspaceEnvironment,
useSavePersonalEnvironment,
useUpsertWorkspaceEnvironment,
useWorkspaceEnvironment,
type WorkspaceEnvironmentData,
} from '@/hooks/queries/environment'
const logger = createLogger('EnvironmentVariables')
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center'
const generateRowId = (() => {
let counter = 0
return () => {
counter += 1
return Date.now() + counter
}
})()
const createEmptyEnvVar = (): UIEnvironmentVariable => ({
key: '',
value: '',
id: generateRowId(),
})
interface UIEnvironmentVariable {
key: string
value: string
id?: number
}
/**
* Validates an environment variable key.
* Returns an error message if invalid, undefined if valid.
*/
function validateEnvVarKey(key: string): string | undefined {
if (!key) return undefined
if (key.includes(' ')) return 'Spaces are not allowed'
if (!isValidEnvVarName(key)) return 'Only letters, numbers, and underscores allowed'
return undefined
}
interface EnvironmentVariablesProps {
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
}
interface WorkspaceVariableRowProps {
envKey: string
value: string
renamingKey: string | null
pendingKeyValue: string
isNewlyPromoted: boolean
onRenameStart: (key: string) => void
onPendingKeyChange: (value: string) => void
onRenameEnd: (key: string, value: string) => void
onDelete: (key: string) => void
onDemote: (key: string, value: string) => void
}
function WorkspaceVariableRow({
envKey,
value,
renamingKey,
pendingKeyValue,
isNewlyPromoted,
onRenameStart,
onPendingKeyChange,
onRenameEnd,
onDelete,
onDemote,
}: WorkspaceVariableRowProps) {
return (
<div className={GRID_COLS}>
<EmcnInput
value={renamingKey === envKey ? pendingKeyValue : envKey}
onChange={(e) => {
if (renamingKey !== envKey) onRenameStart(envKey)
onPendingKeyChange(e.target.value)
}}
onBlur={() => onRenameEnd(envKey, value)}
name={`workspace_env_key_${envKey}_${Math.random()}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className='h-9'
/>
<div />
<EmcnInput
value={value ? '•'.repeat(value.length) : ''}
readOnly
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
className='h-9'
/>
<div className='ml-[8px] flex'>
{isNewlyPromoted && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => onDemote(envKey, value)} className='h-9 w-9'>
<Undo2 className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Change to personal scope</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => onDelete(envKey)} className='h-9 w-9'>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)
}
export function EnvironmentVariables({ registerBeforeLeaveHandler }: EnvironmentVariablesProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data: personalEnvData, isLoading: isPersonalLoading } = usePersonalEnvironment()
const { data: workspaceEnvData, isLoading: isWorkspaceLoading } = useWorkspaceEnvironment(
workspaceId,
{
select: useCallback(
(data: WorkspaceEnvironmentData): WorkspaceEnvironmentData => ({
workspace: data.workspace || {},
personal: data.personal || {},
conflicts: data.conflicts || [],
}),
[]
),
}
)
const savePersonalMutation = useSavePersonalEnvironment()
const upsertWorkspaceMutation = useUpsertWorkspaceEnvironment()
const removeWorkspaceMutation = useRemoveWorkspaceEnvironment()
const isLoading = isPersonalLoading || isWorkspaceLoading
const variables = useMemo(() => personalEnvData || {}, [personalEnvData])
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const [workspaceVars, setWorkspaceVars] = useState<Record<string, string>>({})
const [conflicts, setConflicts] = useState<string[]>([])
const [renamingKey, setRenamingKey] = useState<string | null>(null)
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
const [changeToken, setChangeToken] = useState(0)
const initialWorkspaceVarsRef = useRef<Record<string, string>>({})
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingProceedCallback = useRef<(() => void) | null>(null)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
const hasChangesRef = useRef(false)
const hasSavedRef = useRef(false)
const filteredEnvVars = useMemo(() => {
const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index }))
if (!searchTerm.trim()) return mapped
const term = searchTerm.toLowerCase()
return mapped.filter(({ envVar }) => envVar.key.toLowerCase().includes(term))
}, [envVars, searchTerm])
const filteredWorkspaceEntries = useMemo(() => {
const entries = Object.entries(workspaceVars)
if (!searchTerm.trim()) return entries
const term = searchTerm.toLowerCase()
return entries.filter(([key]) => key.toLowerCase().includes(term))
}, [workspaceVars, searchTerm])
const hasChanges = useMemo(() => {
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
const currentVars = envVars.filter((v) => v.key || v.value)
const initialMap = new Map(initialVars.map((v) => [v.key, v.value]))
const currentMap = new Map(currentVars.map((v) => [v.key, v.value]))
if (initialMap.size !== currentMap.size) return true
for (const [key, value] of currentMap) {
if (initialMap.get(key) !== value) return true
}
for (const key of initialMap.keys()) {
if (!currentMap.has(key)) return true
}
const before = initialWorkspaceVarsRef.current
const after = workspaceVars
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
if (Object.keys(before).length !== Object.keys(after).length) return true
for (const key of allKeys) {
if (before[key] !== after[key]) return true
}
return false
}, [envVars, workspaceVars, changeToken])
const hasConflicts = useMemo(() => {
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
}, [envVars, workspaceVars])
const hasInvalidKeys = useMemo(() => {
return envVars.some((envVar) => !!envVar.key && validateEnvVarKey(envVar.key))
}, [envVars])
useEffect(() => {
hasChangesRef.current = hasChanges
}, [hasChanges])
const handleBeforeLeave = useCallback((onProceed: () => void) => {
if (hasChangesRef.current) {
setShowUnsavedChanges(true)
pendingProceedCallback.current = onProceed
} else {
onProceed()
}
}, [])
useEffect(() => {
if (hasSavedRef.current) return
const existingVars = Object.values(variables)
const initialVars = existingVars.length
? existingVars.map((envVar) => ({
...envVar,
id: generateRowId(),
}))
: [createEmptyEnvVar()]
initialVarsRef.current = JSON.parse(JSON.stringify(initialVars))
setEnvVars(JSON.parse(JSON.stringify(initialVars)))
pendingProceedCallback.current = null
}, [variables])
useEffect(() => {
if (workspaceEnvData) {
if (hasSavedRef.current) {
setConflicts(workspaceEnvData?.conflicts || [])
hasSavedRef.current = false
} else {
setWorkspaceVars(workspaceEnvData?.workspace || {})
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
setConflicts(workspaceEnvData?.conflicts || [])
}
}
}, [workspaceEnvData])
useEffect(() => {
if (registerBeforeLeaveHandler) {
registerBeforeLeaveHandler(handleBeforeLeave)
}
}, [registerBeforeLeaveHandler, handleBeforeLeave])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
setShouldScrollToBottom(false)
}
}, [shouldScrollToBottom])
useEffect(() => {
const personalKeys = envVars.map((envVar) => envVar.key.trim()).filter((key) => key.length > 0)
const uniquePersonalKeys = Array.from(new Set(personalKeys))
const computedConflicts = uniquePersonalKeys.filter((key) => Object.hasOwn(workspaceVars, key))
setConflicts((prev) => {
if (prev.length === computedConflicts.length) {
const sameKeys = prev.every((key) => computedConflicts.includes(key))
if (sameKeys) return prev
}
return computedConflicts
})
}, [envVars, workspaceVars])
const handleWorkspaceKeyRename = useCallback(
(currentKey: string, currentValue: string) => {
const newKey = pendingKeyValue.trim()
if (!renamingKey || renamingKey !== currentKey) return
setRenamingKey(null)
if (!newKey || newKey === currentKey) return
setWorkspaceVars((prev) => {
const next = { ...prev }
delete next[currentKey]
next[newKey] = currentValue
return next
})
},
[pendingKeyValue, renamingKey]
)
const handleDeleteWorkspaceVar = useCallback((key: string) => {
setWorkspaceVars((prev) => {
const next = { ...prev }
delete next[key]
return next
})
}, [])
const addEnvVar = useCallback(() => {
setEnvVars((prev) => [...prev, createEmptyEnvVar()])
setSearchTerm('')
setShouldScrollToBottom(true)
}, [])
const updateEnvVar = useCallback((index: number, field: 'key' | 'value', value: string) => {
setEnvVars((prev) => {
const newEnvVars = [...prev]
newEnvVars[index][field] = value
return newEnvVars
})
}, [])
const removeEnvVar = useCallback((index: number) => {
setEnvVars((prev) => {
const newEnvVars = prev.filter((_, i) => i !== index)
return newEnvVars.length ? newEnvVars : [createEmptyEnvVar()]
})
}, [])
const handleValueFocus = useCallback((index: number, e: React.FocusEvent<HTMLInputElement>) => {
setFocusedValueIndex(index)
e.target.scrollLeft = 0
}, [])
const handleValueClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
e.currentTarget.scrollLeft = 0
}, [])
const parseEnvVarLine = useCallback((line: string): UIEnvironmentVariable | null => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return null
const withoutExport = trimmed.replace(/^export\s+/, '')
const equalIndex = withoutExport.indexOf('=')
if (equalIndex === -1 || equalIndex === 0) return null
const potentialKey = withoutExport.substring(0, equalIndex).trim()
if (!isValidEnvVarName(potentialKey)) return null
let value = withoutExport.substring(equalIndex + 1)
const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_')
const valueIsJustPadding = /^=+$/.test(value.trim())
if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) {
return null
}
const trimmedValue = value.trim()
if (
!trimmedValue.startsWith('"') &&
!trimmedValue.startsWith("'") &&
!trimmedValue.startsWith('`')
) {
const commentIndex = value.search(/\s#/)
if (commentIndex !== -1) {
value = value.substring(0, commentIndex)
}
}
value = value.trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('`') && value.endsWith('`'))
) {
value = value.slice(1, -1)
}
return { key: potentialKey, value, id: generateRowId() }
}, [])
const handleSingleValuePaste = useCallback(
(text: string, index: number, inputType: 'key' | 'value') => {
setEnvVars((prev) => {
const newEnvVars = [...prev]
newEnvVars[index][inputType] = text
return newEnvVars
})
},
[]
)
const handleKeyValuePaste = useCallback(
(lines: string[]) => {
const parsedVars = lines
.map(parseEnvVarLine)
.filter((parsed): parsed is UIEnvironmentVariable => parsed !== null)
.filter(({ key, value }) => key && value)
if (parsedVars.length > 0) {
setEnvVars((prev) => {
const existingVars = prev.filter((v) => v.key || v.value)
return [...existingVars, ...parsedVars]
})
setShouldScrollToBottom(true)
}
},
[parseEnvVarLine]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
const text = e.clipboardData.getData('text').trim()
if (!text) return
const lines = text.split('\n').filter((line) => line.trim())
if (lines.length === 0) return
e.preventDefault()
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
| 'key'
| 'value'
if (inputType) {
const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null)
if (!hasValidEnvVarPattern) {
handleSingleValuePaste(text, index, inputType)
return
}
}
handleKeyValuePaste(lines)
},
[parseEnvVarLine, handleSingleValuePaste, handleKeyValuePaste]
)
const handleCancel = useCallback(() => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setWorkspaceVars({ ...initialWorkspaceVarsRef.current })
setShowUnsavedChanges(false)
pendingProceedCallback.current?.()
pendingProceedCallback.current = null
}, [])
const handleSave = useCallback(async () => {
const onProceed = pendingProceedCallback.current
const prevInitialVars = [...initialVarsRef.current]
const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current }
try {
setShowUnsavedChanges(false)
hasSavedRef.current = true
initialWorkspaceVarsRef.current = { ...workspaceVars }
initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value)))
setChangeToken((prev) => prev + 1)
const validVariables = envVars
.filter((v) => v.key && v.value)
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
await savePersonalMutation.mutateAsync({ variables: validVariables })
const before = prevInitialWorkspaceVars
const after = workspaceVars
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
for (const [k, v] of Object.entries(after)) {
if (!(k in before) || before[k] !== v) {
toUpsert[k] = v
}
}
for (const k of Object.keys(before)) {
if (!(k in after)) toDelete.push(k)
}
if (workspaceId) {
if (Object.keys(toUpsert).length) {
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
}
if (toDelete.length) {
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
}
}
onProceed?.()
pendingProceedCallback.current = null
} catch (error) {
hasSavedRef.current = false
initialVarsRef.current = prevInitialVars
initialWorkspaceVarsRef.current = prevInitialWorkspaceVars
logger.error('Failed to save environment variables:', error)
}
}, [
envVars,
workspaceVars,
workspaceId,
savePersonalMutation,
upsertWorkspaceMutation,
removeWorkspaceMutation,
])
const promoteToWorkspace = useCallback(
(envVar: UIEnvironmentVariable) => {
if (!envVar.key || !envVar.value || !workspaceId) return
setWorkspaceVars((prev) => ({ ...prev, [envVar.key]: envVar.value }))
setEnvVars((prev) => {
const filtered = prev.filter((entry) => entry !== envVar)
return filtered.length ? filtered : [createEmptyEnvVar()]
})
},
[workspaceId]
)
const demoteToPersonal = useCallback((key: string, value: string) => {
if (!key) return
setWorkspaceVars((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setEnvVars((prev) => [...prev, { key, value, id: generateRowId() }])
}, [])
const conflictClassName = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]'
const renderEnvVarRow = useCallback(
(envVar: UIEnvironmentVariable, originalIndex: number) => {
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
const keyError = validateEnvVarKey(envVar.key)
const maskedValueStyle =
focusedValueIndex !== originalIndex && !isConflict
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
: undefined
return (
<>
<div className={GRID_COLS}>
<EmcnInput
data-input-type='key'
value={envVar.key}
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder='API_KEY'
name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className={`h-9 ${isConflict ? conflictClassName : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`}
/>
<div />
<EmcnInput
data-input-type='value'
value={envVar.value}
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
type='text'
onFocus={(e) => {
if (!isConflict) {
e.target.removeAttribute('readOnly')
handleValueFocus(originalIndex, e)
}
}}
onClick={handleValueClick}
onBlur={() => setFocusedValueIndex(null)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder={isConflict ? 'Workspace override active' : 'Enter value'}
disabled={isConflict}
aria-disabled={isConflict}
name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly={isConflict}
style={maskedValueStyle}
className={`h-9 ${isConflict ? `cursor-not-allowed ${conflictClassName}` : ''}`}
/>
<div className='ml-[8px] flex items-center'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
disabled={!envVar.key || !envVar.value || isConflict || !workspaceId}
onClick={() => promoteToWorkspace(envVar)}
className='h-9 w-9'
>
<Share2 className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Change to workspace scope</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeEnvVar(originalIndex)}
className='h-9 w-9'
>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{keyError && (
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
{keyError}
</div>
)}
{isConflict && !keyError && (
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
Workspace variable with the same name overrides this. Rename your personal key to use
it.
</div>
)}
</>
)
},
[
workspaceVars,
workspaceId,
focusedValueIndex,
updateEnvVar,
handlePaste,
handleValueFocus,
handleValueClick,
promoteToWorkspace,
removeEnvVar,
]
)
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='hidden'>
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
tabIndex={-1}
readOnly
/>
</div>
<div className='flex items-center gap-[8px]'>
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search variables...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
name='env_search_field'
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={addEnvVar} variant='tertiary' disabled={isLoading}>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleSave}
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
variant='tertiary'
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
>
Save
</Button>
</Tooltip.Trigger>
{hasConflicts && <Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>}
{hasInvalidKeys && !hasConflicts && (
<Tooltip.Content>Fix invalid variable names before saving</Tooltip.Content>
)}
</Tooltip.Root>
</div>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{isLoading ? (
<>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-5 w-[70px]' />
<div className='text-[13px] text-[var(--text-muted)]'>
<Skeleton className='h-5 w-[160px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-5 w-[55px]' />
{Array.from({ length: 2 }, (_, i) => (
<div key={`personal-${i}`} className={GRID_COLS}>
<Skeleton className='h-9 rounded-[6px]' />
<div />
<Skeleton className='h-9 rounded-[6px]' />
<div className='ml-[8px] flex items-center gap-0'>
<Skeleton className='h-9 w-9 rounded-[6px]' />
<Skeleton className='h-9 w-9 rounded-[6px]' />
</div>
</div>
))}
</div>
</>
) : (
<>
{(!searchTerm.trim() || filteredWorkspaceEntries.length > 0) && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Workspace
</div>
{!searchTerm.trim() && Object.keys(workspaceVars).length === 0 ? (
<div className='text-[13px] text-[var(--text-muted)]'>
No workspace variables yet
</div>
) : (
(searchTerm.trim()
? filteredWorkspaceEntries
: Object.entries(workspaceVars)
).map(([key, value]) => (
<WorkspaceVariableRow
key={key}
envKey={key}
value={value}
renamingKey={renamingKey}
pendingKeyValue={pendingKeyValue}
isNewlyPromoted={!Object.hasOwn(initialWorkspaceVarsRef.current, key)}
onRenameStart={setRenamingKey}
onPendingKeyChange={setPendingKeyValue}
onRenameEnd={handleWorkspaceKeyRename}
onDelete={handleDeleteWorkspaceVar}
onDemote={demoteToPersonal}
/>
))
)}
</div>
)}
{(!searchTerm.trim() || filteredEnvVars.length > 0) && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Personal
</div>
{filteredEnvVars.map(({ envVar, originalIndex }) => (
<div key={envVar.id || originalIndex}>
{renderEnvVarRow(envVar, originalIndex)}
</div>
))}
</div>
)}
{searchTerm.trim() &&
filteredEnvVars.length === 0 &&
filteredWorkspaceEntries.length === 0 &&
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No environment variables found matching "{searchTerm}"
</div>
)}
</>
)}
</div>
</div>
</div>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
{hasConflicts || hasInvalidKeys
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
: 'You have unsaved changes. Do you want to save them before closing?'}
</p>
</ModalBody>
<ModalFooter>
<Button variant='destructive' onClick={handleCancel}>
Discard Changes
</Button>
{hasConflicts || hasInvalidKeys ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
disabled={true}
variant='tertiary'
className='cursor-not-allowed opacity-50'
>
Save Changes
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{hasConflicts
? 'Resolve all conflicts before saving'
: 'Fix invalid variable names before saving'}
</Tooltip.Content>
</Tooltip.Root>
) : (
<Button onClick={handleSave} variant='tertiary'>
Save Changes
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -2,12 +2,11 @@ export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok' export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets' export { CredentialSets } from './credential-sets/credential-sets'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools' export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug' export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files' export { Files as FileUploads } from './files/files'
export { General } from './general/general' export { General } from './general/general'
export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp' export { MCP } from './mcp/mcp'
export { Skills } from './skills/skills' export { Skills } from './skills/skills'
export { Subscription } from './subscription/subscription' export { Subscription } from './subscription/subscription'

View File

@@ -1,417 +0,0 @@
'use client'
import { createElement, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { OAUTH_PROVIDERS } from '@/lib/oauth'
import {
type ServiceInfo,
useConnectOAuthService,
useDisconnectOAuthService,
useOAuthConnections,
} from '@/hooks/queries/oauth-connections'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Integrations')
/**
* Static skeleton structure matching OAUTH_PROVIDERS layout
* Each entry: [providerName, serviceCount]
*/
const SKELETON_STRUCTURE: [string, number][] = [
['Google', 7],
['Microsoft', 6],
['GitHub', 1],
['X', 1],
['Confluence', 1],
['Jira', 1],
['Airtable', 1],
['Notion', 1],
['Linear', 1],
['Slack', 1],
['Reddit', 1],
['Wealthbox', 1],
['Webflow', 1],
['Trello', 1],
['Asana', 1],
['Pipedrive', 1],
['HubSpot', 1],
['Salesforce', 1],
]
function IntegrationsSkeleton() {
return (
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex w-full items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<Input
placeholder='Search integrations...'
disabled
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{SKELETON_STRUCTURE.map(([providerName, serviceCount]) => (
<div key={providerName} className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[60px]' />
{Array.from({ length: serviceCount }).map((_, index) => (
<div key={index} className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
<div className='flex flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
</div>
))}
</div>
))}
</div>
</div>
</div>
)
}
interface IntegrationsProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
export function Integrations({ onOpenChange, registerCloseHandler }: IntegrationsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const pendingServiceRef = useRef<HTMLDivElement>(null)
const { data: services = [], isPending } = useOAuthConnections()
const connectService = useConnectOAuthService()
const disconnectService = useDisconnectOAuthService()
const { config: permissionConfig } = usePermissionConfig()
const [searchTerm, setSearchTerm] = useState('')
const [pendingService, setPendingService] = useState<string | null>(null)
const [authSuccess, setAuthSuccess] = useState(false)
const [showActionRequired, setShowActionRequired] = useState(false)
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
const connectionAddedRef = useRef<boolean>(false)
// Disconnect confirmation dialog state
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
const [serviceToDisconnect, setServiceToDisconnect] = useState<{
service: ServiceInfo
accountId: string
} | null>(null)
// Check for OAuth callback - just show success message
useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (code && state) {
logger.info('OAuth callback successful')
setAuthSuccess(true)
// Clear URL parameters without changing the page
const url = new URL(window.location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
router.replace(url.pathname + url.search)
} else if (error) {
logger.error('OAuth error:', { error })
}
}, [searchParams, router])
// Track when a new connection is added compared to previous render
useEffect(() => {
try {
const currentConnected = new Set<string>()
services.forEach((svc) => {
if (svc.isConnected) currentConnected.add(svc.id)
})
// Detect new connections by comparing to previous connected set
for (const id of currentConnected) {
if (!prevConnectedIdsRef.current.has(id)) {
connectionAddedRef.current = true
break
}
}
prevConnectedIdsRef.current = currentConnected
} catch {}
}, [services])
// On mount, register a close handler so the parent modal can delegate close events here
useEffect(() => {
if (!registerCloseHandler) return
const handle = (open: boolean) => {
if (open) return
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('oauth-integration-closed', {
detail: { success: connectionAddedRef.current === true },
})
)
}
} catch {}
onOpenChange?.(open)
}
registerCloseHandler(handle)
}, [registerCloseHandler, onOpenChange])
// Handle connect button click
const handleConnect = async (service: ServiceInfo) => {
try {
logger.info('Connecting service:', {
serviceId: service.id,
providerId: service.providerId,
scopes: service.scopes,
})
// better-auth will automatically redirect back to this URL after OAuth
await connectService.mutateAsync({
providerId: service.providerId,
callbackURL: window.location.href,
})
} catch (error) {
logger.error('OAuth connection error:', { error })
}
}
/**
* Opens the disconnect confirmation dialog for a service.
*/
const handleDisconnect = (service: ServiceInfo, accountId: string) => {
setServiceToDisconnect({ service, accountId })
setShowDisconnectDialog(true)
}
/**
* Confirms and executes the service disconnection.
*/
const confirmDisconnect = async () => {
if (!serviceToDisconnect) return
setShowDisconnectDialog(false)
const { service, accountId } = serviceToDisconnect
setServiceToDisconnect(null)
try {
await disconnectService.mutateAsync({
provider: service.providerId.split('-')[0],
providerId: service.providerId,
serviceId: service.id,
accountId,
})
} catch (error) {
logger.error('Error disconnecting service:', { error })
}
}
// Group services by provider, filtering by permission config
const groupedServices = services.reduce(
(acc, service) => {
// Filter based on allowedIntegrations
if (
permissionConfig.allowedIntegrations !== null &&
!permissionConfig.allowedIntegrations.includes(service.id)
) {
return acc
}
// Find the provider for this service
const providerKey =
Object.keys(OAUTH_PROVIDERS).find((key) =>
Object.keys(OAUTH_PROVIDERS[key].services).includes(service.id)
) || 'other'
if (!acc[providerKey]) {
acc[providerKey] = []
}
acc[providerKey].push(service)
return acc
},
{} as Record<string, ServiceInfo[]>
)
// Filter services based on search term
const filteredGroupedServices = Object.entries(groupedServices).reduce(
(acc, [providerKey, providerServices]) => {
const filteredServices = providerServices.filter(
(service) =>
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
service.description.toLowerCase().includes(searchTerm.toLowerCase())
)
if (filteredServices.length > 0) {
acc[providerKey] = filteredServices
}
return acc
},
{} as Record<string, ServiceInfo[]>
)
const scrollToHighlightedService = () => {
if (pendingServiceRef.current) {
pendingServiceRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
if (isPending) {
return <IntegrationsSkeleton />
}
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex w-full items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<Input
placeholder='Search services...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{authSuccess && (
<div className='flex items-center gap-[12px] rounded-[8px] border border-green-200 bg-green-50 p-[12px]'>
<Check className='h-4 w-4 flex-shrink-0 text-green-500' />
<p className='font-medium text-[13px] text-green-800'>
Account connected successfully!
</p>
</div>
)}
{pendingService && showActionRequired && (
<div className='flex items-start gap-[12px] rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[12px]'>
<ExternalLink className='mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--text-muted)]' />
<div className='flex flex-1 flex-col gap-[8px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
<span className='font-medium text-[var(--text-primary)]'>Action Required:</span>{' '}
Please connect your account to enable the requested features.
</p>
<Button variant='outline' onClick={scrollToHighlightedService}>
<span>Go to service</span>
<ChevronDown className='h-3 w-3' />
</Button>
</div>
</div>
)}
<div className='flex flex-col gap-[16px]'>
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='flex flex-col gap-[8px]'>
<Label className='text-[12px] text-[var(--text-tertiary)]'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</Label>
{providerServices.map((service) => (
<div
key={service.id}
className={cn(
'flex items-center justify-between',
pendingService === service.id &&
'-m-[8px] rounded-[8px] bg-[var(--bg)] p-[8px]'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex items-center gap-[12px]'>
<div className='flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'>
{createElement(service.icon, { className: 'h-4 w-4' })}
</div>
<div className='flex flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{service.name}</span>
{service.accounts && service.accounts.length > 0 ? (
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{service.accounts.map((a) => a.name).join(', ')}
</p>
) : (
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{service.description}
</p>
)}
</div>
</div>
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={disconnectService.isPending}
>
Disconnect
</Button>
) : (
<Button
variant='tertiary'
onClick={() => handleConnect(service)}
disabled={connectService.isPending}
>
Connect
</Button>
)}
</div>
))}
</div>
))}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No services found matching "{searchTerm}"
</div>
)}
</div>
</div>
</div>
</div>
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
<ModalContent size='sm'>
<ModalHeader>Disconnect Service</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to disconnect{' '}
<span className='font-medium text-[var(--text-primary)]'>
{serviceToDisconnect?.service.name}
</span>
?{' '}
<span className='text-[var(--text-error)]'>
This will revoke access and you will need to reconnect to use this service.
</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDisconnectDialog(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={confirmDisconnect}>
Disconnect
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
@@ -20,7 +20,6 @@ import {
import { import {
Card, Card,
Connections, Connections,
FolderCode,
HexSimple, HexSimple,
Key, Key,
SModal, SModal,
@@ -45,12 +44,11 @@ import {
BYOK, BYOK,
Copilot, Copilot,
CredentialSets, CredentialSets,
Credentials,
CustomTools, CustomTools,
Debug, Debug,
EnvironmentVariables,
FileUploads, FileUploads,
General, General,
Integrations,
MCP, MCP,
Skills, Skills,
Subscription, Subscription,
@@ -80,9 +78,8 @@ interface SettingsModalProps {
type SettingsSection = type SettingsSection =
| 'general' | 'general'
| 'environment' | 'credentials'
| 'template-profile' | 'template-profile'
| 'integrations'
| 'credential-sets' | 'credential-sets'
| 'access-control' | 'access-control'
| 'apikeys' | 'apikeys'
@@ -156,11 +153,10 @@ const allNavigationItems: NavigationItem[] = [
requiresHosted: true, requiresHosted: true,
requiresTeam: true, requiresTeam: true,
}, },
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' }, { id: 'credentials', label: 'Credentials', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' }, { id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' }, { id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' }, { id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' }, { id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' }, { id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{ {
@@ -218,8 +214,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const activeOrganization = organizationsData?.activeOrganization const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig() const { config: permissionConfig } = usePermissionConfig()
const environmentBeforeLeaveHandler = useRef<((onProceed: () => void) => void) | null>(null)
const integrationsCloseHandler = useRef<((open: boolean) => void) | null>(null)
const userEmail = session?.user?.email const userEmail = session?.user?.email
const userId = session?.user?.id const userId = session?.user?.id
@@ -256,9 +250,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) { if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false return false
} }
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
return false
}
if (item.id === 'files' && permissionConfig.hideFilesTab) { if (item.id === 'files' && permissionConfig.hideFilesTab) {
return false return false
} }
@@ -327,26 +318,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return activeSection return activeSection
}, [activeSection]) }, [activeSection])
const registerEnvironmentBeforeLeaveHandler = useCallback(
(handler: (onProceed: () => void) => void) => {
environmentBeforeLeaveHandler.current = handler
},
[]
)
const registerIntegrationsCloseHandler = useCallback((handler: (open: boolean) => void) => {
integrationsCloseHandler.current = handler
}, [])
const handleSectionChange = useCallback( const handleSectionChange = useCallback(
(sectionId: SettingsSection) => { (sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return if (sectionId === effectiveActiveSection) return
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return
}
setActiveSection(sectionId) setActiveSection(sectionId)
}, },
[effectiveActiveSection] [effectiveActiveSection]
@@ -475,23 +449,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
} }
} }
// Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => { const handleDialogOpenChange = (newOpen: boolean) => {
if ( onOpenChange(newOpen)
!newOpen &&
effectiveActiveSection === 'environment' &&
environmentBeforeLeaveHandler.current
) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
} else if (
!newOpen &&
effectiveActiveSection === 'integrations' &&
integrationsCloseHandler.current
) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
}
} }
return ( return (
@@ -502,7 +461,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</VisuallyHidden.Root> </VisuallyHidden.Root>
<VisuallyHidden.Root> <VisuallyHidden.Root>
<DialogPrimitive.Description> <DialogPrimitive.Description>
Configure your workspace settings, environment variables, integrations, and preferences Configure your workspace settings, credentials, and preferences
</DialogPrimitive.Description> </DialogPrimitive.Description>
</VisuallyHidden.Root> </VisuallyHidden.Root>
@@ -539,18 +498,10 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</SModalMainHeader> </SModalMainHeader>
<SModalMainBody> <SModalMainBody>
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />} {effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'environment' && ( {effectiveActiveSection === 'credentials' && (
<EnvironmentVariables <Credentials onOpenChange={onOpenChange} />
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
/>
)} )}
{effectiveActiveSection === 'template-profile' && <TemplateProfile />} {effectiveActiveSection === 'template-profile' && <TemplateProfile />}
{effectiveActiveSection === 'integrations' && (
<Integrations
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
{effectiveActiveSection === 'credential-sets' && <CredentialSets />} {effectiveActiveSection === 'credential-sets' && <CredentialSets />}
{effectiveActiveSection === 'access-control' && <AccessControl />} {effectiveActiveSection === 'access-control' && <AccessControl />}
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />} {effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}

View File

@@ -2,10 +2,11 @@ import { createLogger } from '@sim/logger'
import { AgentIcon } from '@/components/icons' import { AgentIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types' import { AuthMode } from '@/blocks/types'
import { getApiKeyCondition, getModelOptions } from '@/blocks/utils' import { getApiKeyCondition } from '@/blocks/utils'
import { import {
getBaseModelProviders, getBaseModelProviders,
getMaxTemperature, getMaxTemperature,
getProviderIcon,
getReasoningEffortValuesForModel, getReasoningEffortValuesForModel,
getThinkingLevelsForModel, getThinkingLevelsForModel,
getVerbosityValuesForModel, getVerbosityValuesForModel,
@@ -17,6 +18,7 @@ import {
providers, providers,
supportsTemperature, supportsTemperature,
} from '@/providers/utils' } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
const logger = createLogger('AgentBlock') const logger = createLogger('AgentBlock')
@@ -119,13 +121,29 @@ Return ONLY the JSON array.`,
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
{ {
id: 'vertexCredential', id: 'vertexCredential',
title: 'Google Cloud Account', title: 'Google Cloud Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'vertex-ai', serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account', placeholder: 'Select Google Cloud account',
required: true, required: true,
@@ -134,6 +152,19 @@ Return ONLY the JSON array.`,
value: providers.vertex.models, value: providers.vertex.models,
}, },
}, },
{
id: 'manualCredential',
title: 'Google Cloud Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{ {
id: 'reasoningEffort', id: 'reasoningEffort',
title: 'Reasoning Effort', title: 'Reasoning Effort',
@@ -732,6 +763,7 @@ Example 3 (Array Input):
apiKey: { type: 'string', description: 'Provider API key' }, apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' }, azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' }, azureApiVersion: { type: 'string', description: 'Azure API version' },
oauthCredential: { type: 'string', description: 'OAuth credential for Vertex AI' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },

View File

@@ -32,6 +32,8 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
id: 'credential', id: 'credential',
title: 'Airtable Account', title: 'Airtable Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable', serviceId: 'airtable',
requiredScopes: [ requiredScopes: [
'data.records:read', 'data.records:read',
@@ -42,6 +44,15 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
placeholder: 'Select Airtable account', placeholder: 'Select Airtable account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Airtable Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'baseId', id: 'baseId',
title: 'Base ID', title: 'Base ID',
@@ -219,7 +230,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, records, fields, ...rest } = params const { oauthCredential, records, fields, ...rest } = params
let parsedRecords: any | undefined let parsedRecords: any | undefined
let parsedFields: any | undefined let parsedFields: any | undefined
@@ -237,7 +248,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
// Construct parameters based on operation // Construct parameters based on operation
const baseParams = { const baseParams = {
credential, credential: oauthCredential,
...rest, ...rest,
} }
@@ -255,7 +266,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Airtable access token' }, oauthCredential: { type: 'string', description: 'Airtable access token' },
baseId: { type: 'string', description: 'Airtable base identifier' }, baseId: { type: 'string', description: 'Airtable base identifier' },
tableId: { type: 'string', description: 'Airtable table identifier' }, tableId: { type: 'string', description: 'Airtable table identifier' },
// Conditional inputs // Conditional inputs

View File

@@ -32,12 +32,22 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
id: 'credential', id: 'credential',
title: 'Asana Account', title: 'Asana Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'asana', serviceId: 'asana',
requiredScopes: ['default'], requiredScopes: ['default'],
placeholder: 'Select Asana account', placeholder: 'Select Asana account',
}, },
{
id: 'manualCredential',
title: 'Asana Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'workspace', id: 'workspace',
title: 'Workspace GID', title: 'Workspace GID',
@@ -215,7 +225,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation } = params const { oauthCredential, operation } = params
const projectsArray = params.projects const projectsArray = params.projects
? params.projects ? params.projects
@@ -225,7 +235,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
: undefined : undefined
const baseParams = { const baseParams = {
accessToken: credential?.accessToken, accessToken: oauthCredential?.accessToken,
} }
switch (operation) { switch (operation) {
@@ -284,6 +294,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Asana OAuth credential' },
workspace: { type: 'string', description: 'Workspace GID' }, workspace: { type: 'string', description: 'Workspace GID' },
taskGid: { type: 'string', description: 'Task GID' }, taskGid: { type: 'string', description: 'Task GID' },
getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' }, getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' },

View File

@@ -49,9 +49,20 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
title: 'Cal.com Account', title: 'Cal.com Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'calcom', serviceId: 'calcom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
placeholder: 'Select Cal.com account', placeholder: 'Select Cal.com account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Cal.com Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// === Create Booking fields === // === Create Booking fields ===
{ {
@@ -555,7 +566,7 @@ Return ONLY valid JSON - no explanations.`,
params: (params) => { params: (params) => {
const { const {
operation, operation,
credential, oauthCredential,
attendeeName, attendeeName,
attendeeEmail, attendeeEmail,
attendeeTimeZone, attendeeTimeZone,
@@ -745,7 +756,7 @@ Return ONLY valid JSON - no explanations.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Cal.com OAuth credential' }, oauthCredential: { type: 'string', description: 'Cal.com OAuth credential' },
eventTypeId: { type: 'number', description: 'Event type ID' }, eventTypeId: { type: 'number', description: 'Event type ID' },
start: { type: 'string', description: 'Start time (ISO 8601)' }, start: { type: 'string', description: 'Start time (ISO 8601)' },
end: { type: 'string', description: 'End time (ISO 8601)' }, end: { type: 'string', description: 'End time (ISO 8601)' },

View File

@@ -51,6 +51,8 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
id: 'credential', id: 'credential',
title: 'Confluence Account', title: 'Confluence Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence', serviceId: 'confluence',
requiredScopes: [ requiredScopes: [
'read:confluence-content.all', 'read:confluence-content.all',
@@ -85,6 +87,15 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence account', placeholder: 'Select Confluence account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Confluence Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'pageId', id: 'pageId',
title: 'Select Page', title: 'Select Page',
@@ -287,7 +298,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
pageId, pageId,
operation, operation,
attachmentFile, attachmentFile,
@@ -300,7 +311,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
if (operation === 'upload_attachment') { if (operation === 'upload_attachment') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
file: attachmentFile, file: attachmentFile,
@@ -311,7 +322,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
operation, operation,
...rest, ...rest,
@@ -322,7 +333,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' }, domain: { type: 'string', description: 'Confluence domain' },
credential: { type: 'string', description: 'Confluence access token' }, oauthCredential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' }, pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' }, spaceId: { type: 'string', description: 'Space identifier' },
title: { type: 'string', description: 'Page title' }, title: { type: 'string', description: 'Page title' },
@@ -428,6 +439,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
id: 'credential', id: 'credential',
title: 'Confluence Account', title: 'Confluence Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence', serviceId: 'confluence',
requiredScopes: [ requiredScopes: [
'read:confluence-content.all', 'read:confluence-content.all',
@@ -462,6 +475,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence account', placeholder: 'Select Confluence account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Confluence Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'domain', id: 'domain',
title: 'Domain', title: 'Domain',
@@ -943,7 +965,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
pageId, pageId,
operation, operation,
attachmentFile, attachmentFile,
@@ -968,7 +990,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'add_label') { if (operation === 'add_label') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
prefix: labelPrefix || 'global', prefix: labelPrefix || 'global',
@@ -978,7 +1000,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'create_blogpost') { if (operation === 'create_blogpost') {
return { return {
credential, credential: oauthCredential,
operation, operation,
status: blogPostStatus || 'current', status: blogPostStatus || 'current',
...rest, ...rest,
@@ -987,7 +1009,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'delete') { if (operation === 'delete') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
purge: purge || false, purge: purge || false,
@@ -997,7 +1019,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'list_comments') { if (operation === 'list_comments') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
bodyFormat: bodyFormat || 'storage', bodyFormat: bodyFormat || 'storage',
@@ -1023,7 +1045,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (supportsCursor.includes(operation) && cursor) { if (supportsCursor.includes(operation) && cursor) {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
operation, operation,
cursor, cursor,
@@ -1036,7 +1058,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('Property key is required for this operation.') throw new Error('Property key is required for this operation.')
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
key: propertyKey, key: propertyKey,
@@ -1047,7 +1069,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'delete_page_property') { if (operation === 'delete_page_property') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
propertyId, propertyId,
@@ -1057,7 +1079,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'get_pages_by_label') { if (operation === 'get_pages_by_label') {
return { return {
credential, credential: oauthCredential,
operation, operation,
labelId, labelId,
cursor: cursor || undefined, cursor: cursor || undefined,
@@ -1067,7 +1089,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'list_space_labels') { if (operation === 'list_space_labels') {
return { return {
credential, credential: oauthCredential,
operation, operation,
cursor: cursor || undefined, cursor: cursor || undefined,
...rest, ...rest,
@@ -1080,7 +1102,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('File is required for upload attachment operation.') throw new Error('File is required for upload attachment operation.')
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
file: normalizedFile, file: normalizedFile,
@@ -1091,7 +1113,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
blogPostId: blogPostId || undefined, blogPostId: blogPostId || undefined,
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined, versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
@@ -1104,7 +1126,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' }, domain: { type: 'string', description: 'Confluence domain' },
credential: { type: 'string', description: 'Confluence access token' }, oauthCredential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' }, pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' }, spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' }, blogPostId: { type: 'string', description: 'Blog post identifier' },

View File

@@ -38,6 +38,8 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
id: 'credential', id: 'credential',
title: 'Dropbox Account', title: 'Dropbox Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'dropbox', serviceId: 'dropbox',
requiredScopes: [ requiredScopes: [
'account_info.read', 'account_info.read',
@@ -51,6 +53,15 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
placeholder: 'Select Dropbox account', placeholder: 'Select Dropbox account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Dropbox Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Upload operation inputs // Upload operation inputs
{ {
id: 'path', id: 'path',
@@ -352,7 +363,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Dropbox OAuth credential' }, oauthCredential: { type: 'string', description: 'Dropbox OAuth credential' },
// Common inputs // Common inputs
path: { type: 'string', description: 'Path in Dropbox' }, path: { type: 'string', description: 'Path in Dropbox' },
autorename: { type: 'boolean', description: 'Auto-rename on conflict' }, autorename: { type: 'boolean', description: 'Auto-rename on conflict' },

View File

@@ -1,13 +1,10 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ChartBarIcon } from '@/components/icons' import { ChartBarIcon } from '@/components/icons'
import type { BlockConfig, ParamType } from '@/blocks/types' import type { BlockConfig, ParamType } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ProviderId } from '@/providers/types' import type { ProviderId } from '@/providers/types'
import { getBaseModelProviders } from '@/providers/utils' import { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
const logger = createLogger('EvaluatorBlock') const logger = createLogger('EvaluatorBlock')
@@ -178,7 +175,21 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
...getProviderCredentialSubBlocks(), ...getProviderCredentialSubBlocks(),
{ {

View File

@@ -76,6 +76,8 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
id: 'credential', id: 'credential',
title: 'Gmail Account', title: 'Gmail Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'gmail', serviceId: 'gmail',
requiredScopes: [ requiredScopes: [
'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.send',
@@ -85,6 +87,15 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
placeholder: 'Select Gmail account', placeholder: 'Select Gmail account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Gmail Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Send Email Fields // Send Email Fields
{ {
id: 'to', id: 'to',
@@ -406,7 +417,7 @@ Return ONLY the search query - no explanations, no extra text.`,
tool: selectGmailToolId, tool: selectGmailToolId,
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
folder, folder,
addLabelIds, addLabelIds,
removeLabelIds, removeLabelIds,
@@ -467,7 +478,7 @@ Return ONLY the search query - no explanations, no extra text.`,
return { return {
...rest, ...rest,
credential, oauthCredential,
...(normalizedAttachments && { attachments: normalizedAttachments }), ...(normalizedAttachments && { attachments: normalizedAttachments }),
} }
}, },
@@ -475,7 +486,7 @@ Return ONLY the search query - no explanations, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Gmail access token' }, oauthCredential: { type: 'string', description: 'Gmail access token' },
// Send operation inputs // Send operation inputs
to: { type: 'string', description: 'Recipient email address' }, to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' }, subject: { type: 'string', description: 'Email subject' },

View File

@@ -39,11 +39,22 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
id: 'credential', id: 'credential',
title: 'Google Calendar Account', title: 'Google Calendar Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-calendar', serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'], requiredScopes: ['https://www.googleapis.com/auth/calendar'],
placeholder: 'Select Google Calendar account', placeholder: 'Select Google Calendar account',
}, },
{
id: 'manualCredential',
title: 'Google Calendar Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Calendar selector (basic mode) - not needed for list_calendars // Calendar selector (basic mode) - not needed for list_calendars
{ {
id: 'calendarId', id: 'calendarId',
@@ -512,7 +523,7 @@ Return ONLY the natural language event text - no explanations.`,
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
attendees, attendees,
replaceExisting, replaceExisting,
@@ -576,7 +587,7 @@ Return ONLY the natural language event text - no explanations.`,
} }
return { return {
credential, oauthCredential,
...processedParams, ...processedParams,
} }
}, },
@@ -584,7 +595,7 @@ Return ONLY the natural language event text - no explanations.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Calendar access token' }, oauthCredential: { type: 'string', description: 'Google Calendar access token' },
calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' }, calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' },
// Create/Update operation inputs // Create/Update operation inputs

View File

@@ -32,6 +32,8 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-docs', serviceId: 'google-docs',
requiredScopes: [ requiredScopes: [
@@ -40,6 +42,15 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Document selector (basic mode) // Document selector (basic mode)
{ {
id: 'documentId', id: 'documentId',
@@ -157,7 +168,7 @@ Return ONLY the document content - no explanations, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, documentId, folderId, ...rest } = params const { oauthCredential, documentId, folderId, ...rest } = params
const effectiveDocumentId = documentId ? String(documentId).trim() : '' const effectiveDocumentId = documentId ? String(documentId).trim() : ''
const effectiveFolderId = folderId ? String(folderId).trim() : '' const effectiveFolderId = folderId ? String(folderId).trim() : ''
@@ -166,14 +177,14 @@ Return ONLY the document content - no explanations, no extra text.`,
...rest, ...rest,
documentId: effectiveDocumentId || undefined, documentId: effectiveDocumentId || undefined,
folderId: effectiveFolderId || undefined, folderId: effectiveFolderId || undefined,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Docs access token' }, oauthCredential: { type: 'string', description: 'Google Docs access token' },
documentId: { type: 'string', description: 'Document identifier (canonical param)' }, documentId: { type: 'string', description: 'Document identifier (canonical param)' },
title: { type: 'string', description: 'Document title' }, title: { type: 'string', description: 'Document title' },
folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' }, folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' },

View File

@@ -44,6 +44,8 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
id: 'credential', id: 'credential',
title: 'Google Drive Account', title: 'Google Drive Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-drive', serviceId: 'google-drive',
requiredScopes: [ requiredScopes: [
@@ -52,6 +54,15 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
], ],
placeholder: 'Select Google Drive account', placeholder: 'Select Google Drive account',
}, },
{
id: 'manualCredential',
title: 'Google Drive Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Create/Upload File Fields // Create/Upload File Fields
{ {
id: 'fileName', id: 'fileName',
@@ -786,7 +797,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId, uploadFolderId,
createFolderParentId, createFolderParentId,
@@ -873,7 +884,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined
return { return {
credential, oauthCredential,
folderId: effectiveFolderId, folderId: effectiveFolderId,
fileId: effectiveFileId, fileId: effectiveFileId,
destinationFolderId: effectiveDestinationFolderId, destinationFolderId: effectiveDestinationFolderId,
@@ -891,7 +902,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Drive access token' }, oauthCredential: { type: 'string', description: 'Google Drive access token' },
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' }, uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' },
createFolderParentId: { type: 'string', description: 'Parent folder for create folder' }, createFolderParentId: { type: 'string', description: 'Parent folder for create folder' },

View File

@@ -34,6 +34,8 @@ export const GoogleFormsBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-forms', serviceId: 'google-forms',
requiredScopes: [ requiredScopes: [
@@ -45,6 +47,15 @@ export const GoogleFormsBlock: BlockConfig = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Form selector (basic mode) // Form selector (basic mode)
{ {
id: 'formSelector', id: 'formSelector',
@@ -233,7 +244,7 @@ Example for "Add a required multiple choice question about favorite color":
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
formId, // Canonical param from formSelector (basic) or manualFormId (advanced) formId, // Canonical param from formSelector (basic) or manualFormId (advanced)
responseId, responseId,
@@ -251,7 +262,7 @@ Example for "Add a required multiple choice question about favorite color":
...rest ...rest
} = params } = params
const baseParams = { ...rest, credential } const baseParams = { ...rest, oauthCredential }
const effectiveFormId = formId ? String(formId).trim() : undefined const effectiveFormId = formId ? String(formId).trim() : undefined
switch (operation) { switch (operation) {
@@ -309,7 +320,7 @@ Example for "Add a required multiple choice question about favorite color":
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google OAuth credential' }, oauthCredential: { type: 'string', description: 'Google OAuth credential' },
formId: { type: 'string', description: 'Google Form ID' }, formId: { type: 'string', description: 'Google Form ID' },
responseId: { type: 'string', description: 'Specific response ID' }, responseId: { type: 'string', description: 'Specific response ID' },
pageSize: { type: 'string', description: 'Max responses to retrieve' }, pageSize: { type: 'string', description: 'Max responses to retrieve' },

View File

@@ -42,6 +42,8 @@ export const GoogleGroupsBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Groups Account', title: 'Google Groups Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-groups', serviceId: 'google-groups',
requiredScopes: [ requiredScopes: [
@@ -50,6 +52,15 @@ export const GoogleGroupsBlock: BlockConfig = {
], ],
placeholder: 'Select Google Workspace account', placeholder: 'Select Google Workspace account',
}, },
{
id: 'manualCredential',
title: 'Google Groups Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'customer', id: 'customer',
@@ -311,12 +322,12 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation, ...rest } = params const { oauthCredential, operation, ...rest } = params
switch (operation) { switch (operation) {
case 'list_groups': case 'list_groups':
return { return {
credential, oauthCredential,
customer: rest.customer, customer: rest.customer,
domain: rest.domain, domain: rest.domain,
query: rest.query, query: rest.query,
@@ -325,19 +336,19 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
case 'get_group': case 'get_group':
case 'delete_group': case 'delete_group':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
} }
case 'create_group': case 'create_group':
return { return {
credential, credential: oauthCredential,
email: rest.email, email: rest.email,
name: rest.name, name: rest.name,
description: rest.description, description: rest.description,
} }
case 'update_group': case 'update_group':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
name: rest.newName, name: rest.newName,
email: rest.newEmail, email: rest.newEmail,
@@ -345,7 +356,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
case 'list_members': case 'list_members':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
maxResults: rest.maxResults ? Number(rest.maxResults) : undefined, maxResults: rest.maxResults ? Number(rest.maxResults) : undefined,
roles: rest.roles, roles: rest.roles,
@@ -353,66 +364,66 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
case 'get_member': case 'get_member':
case 'remove_member': case 'remove_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
} }
case 'add_member': case 'add_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
email: rest.memberEmail, email: rest.memberEmail,
role: rest.role, role: rest.role,
} }
case 'update_member': case 'update_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
role: rest.role, role: rest.role,
} }
case 'has_member': case 'has_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
} }
case 'list_aliases': case 'list_aliases':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
} }
case 'add_alias': case 'add_alias':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
alias: rest.alias, alias: rest.alias,
} }
case 'remove_alias': case 'remove_alias':
return { return {
credential, oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
alias: rest.alias, alias: rest.alias,
} }
case 'get_settings': case 'get_settings':
return { return {
credential, oauthCredential,
groupEmail: rest.groupEmail, groupEmail: rest.groupEmail,
} }
case 'update_settings': case 'update_settings':
return { return {
credential, oauthCredential,
groupEmail: rest.groupEmail, groupEmail: rest.groupEmail,
} }
default: default:
return { credential, ...rest } return { oauthCredential, ...rest }
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Workspace OAuth credential' }, oauthCredential: { type: 'string', description: 'Google Workspace OAuth credential' },
customer: { type: 'string', description: 'Customer ID for listing groups' }, customer: { type: 'string', description: 'Customer ID for listing groups' },
domain: { type: 'string', description: 'Domain filter for listing groups' }, domain: { type: 'string', description: 'Domain filter for listing groups' },
query: { type: 'string', description: 'Search query for filtering groups' }, query: { type: 'string', description: 'Search query for filtering groups' },

View File

@@ -36,6 +36,8 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-sheets', serviceId: 'google-sheets',
requiredScopes: [ requiredScopes: [
@@ -44,6 +46,15 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector // Spreadsheet Selector
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -246,7 +257,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, values, spreadsheetId, ...rest } = params const { oauthCredential, values, spreadsheetId, ...rest } = params
const parsedValues = values ? JSON.parse(values as string) : undefined const parsedValues = values ? JSON.parse(values as string) : undefined
@@ -260,14 +271,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
values: parsedValues, values: parsedValues,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Sheets access token' }, oauthCredential: { type: 'string', description: 'Google Sheets access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
range: { type: 'string', description: 'Cell range' }, range: { type: 'string', description: 'Cell range' },
values: { type: 'string', description: 'Cell values data' }, values: { type: 'string', description: 'Cell values data' },
@@ -323,6 +334,8 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-sheets', serviceId: 'google-sheets',
requiredScopes: [ requiredScopes: [
@@ -331,6 +344,15 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector (basic mode) - not for create operation // Spreadsheet Selector (basic mode) - not for create operation
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -715,7 +737,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
}), }),
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
values, values,
spreadsheetId, spreadsheetId,
sheetName, sheetName,
@@ -739,7 +761,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
title: (title as string)?.trim(), title: (title as string)?.trim(),
sheetTitles: sheetTitlesArray, sheetTitles: sheetTitlesArray,
credential, oauthCredential,
} }
} }
@@ -753,7 +775,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
if (operation === 'get_info') { if (operation === 'get_info') {
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
credential, oauthCredential,
} }
} }
@@ -763,7 +785,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges, ranges: parsedRanges,
credential, oauthCredential,
} }
} }
@@ -774,7 +796,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
data: parsedData, data: parsedData,
credential, oauthCredential,
} }
} }
@@ -784,7 +806,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges, ranges: parsedRanges,
credential, oauthCredential,
} }
} }
@@ -794,7 +816,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sourceSpreadsheetId: effectiveSpreadsheetId, sourceSpreadsheetId: effectiveSpreadsheetId,
sheetId: Number.parseInt(sheetId as string, 10), sheetId: Number.parseInt(sheetId as string, 10),
destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(), destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(),
credential, oauthCredential,
} }
} }
@@ -813,14 +835,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sheetName: effectiveSheetName, sheetName: effectiveSheetName,
cellRange: cellRange ? (cellRange as string).trim() : undefined, cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues, values: parsedValues,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Sheets access token' }, oauthCredential: { type: 'string', description: 'Google Sheets access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' }, sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },

View File

@@ -46,6 +46,8 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-drive', serviceId: 'google-drive',
requiredScopes: [ requiredScopes: [
@@ -54,6 +56,15 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Presentation selector (basic mode) - for operations that need an existing presentation // Presentation selector (basic mode) - for operations that need an existing presentation
{ {
id: 'presentationId', id: 'presentationId',
@@ -662,7 +673,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
presentationId, presentationId,
folderId, folderId,
slideIndex, slideIndex,
@@ -679,7 +690,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
const result: Record<string, any> = { const result: Record<string, any> = {
...rest, ...rest,
presentationId: effectivePresentationId || undefined, presentationId: effectivePresentationId || undefined,
credential, oauthCredential,
} }
// Handle operation-specific params // Handle operation-specific params
@@ -799,7 +810,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Slides access token' }, oauthCredential: { type: 'string', description: 'Google Slides access token' },
presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' }, presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' },
// Write operation // Write operation
slideIndex: { type: 'number', description: 'Slide index to write to' }, slideIndex: { type: 'number', description: 'Slide index to write to' },

View File

@@ -34,6 +34,8 @@ export const GoogleVaultBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Vault Account', title: 'Google Vault Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-vault', serviceId: 'google-vault',
requiredScopes: [ requiredScopes: [
@@ -42,6 +44,15 @@ export const GoogleVaultBlock: BlockConfig = {
], ],
placeholder: 'Select Google Vault account', placeholder: 'Select Google Vault account',
}, },
{
id: 'manualCredential',
title: 'Google Vault Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Create Hold inputs // Create Hold inputs
{ {
id: 'matterId', id: 'matterId',
@@ -438,10 +449,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, holdStartTime, holdEndTime, holdTerms, ...rest } = params const { oauthCredential, holdStartTime, holdEndTime, holdTerms, ...rest } = params
return { return {
...rest, ...rest,
credential, oauthCredential,
// Map hold-specific fields to their tool parameter names // Map hold-specific fields to their tool parameter names
...(holdStartTime && { startTime: holdStartTime }), ...(holdStartTime && { startTime: holdStartTime }),
...(holdEndTime && { endTime: holdEndTime }), ...(holdEndTime && { endTime: holdEndTime }),
@@ -453,7 +464,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
inputs: { inputs: {
// Core inputs // Core inputs
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Vault OAuth credential' }, oauthCredential: { type: 'string', description: 'Google Vault OAuth credential' },
matterId: { type: 'string', description: 'Matter ID' }, matterId: { type: 'string', description: 'Matter ID' },
// Create export inputs // Create export inputs

View File

@@ -1,10 +1,8 @@
import { ShieldCheckIcon } from '@/components/icons' import { ShieldCheckIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions, import { getProviderIcon } from '@/providers/utils'
getProviderCredentialSubBlocks, import { useProvidersStore } from '@/stores/providers/store'
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
export interface GuardrailsResponse extends ToolResponse { export interface GuardrailsResponse extends ToolResponse {
@@ -113,7 +111,21 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
type: 'combobox', type: 'combobox',
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
condition: { condition: {
field: 'validationType', field: 'validationType',
value: ['hallucination'], value: ['hallucination'],

View File

@@ -39,6 +39,8 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
id: 'credential', id: 'credential',
title: 'HubSpot Account', title: 'HubSpot Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'hubspot', serviceId: 'hubspot',
requiredScopes: [ requiredScopes: [
'crm.objects.contacts.read', 'crm.objects.contacts.read',
@@ -68,6 +70,15 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
placeholder: 'Select HubSpot account', placeholder: 'Select HubSpot account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'HubSpot Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'contactId', id: 'contactId',
title: 'Contact ID or Email', title: 'Contact ID or Email',
@@ -823,7 +834,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
propertiesToSet, propertiesToSet,
properties, properties,
@@ -835,7 +846,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
} = params } = params
const cleanParams: Record<string, any> = { const cleanParams: Record<string, any> = {
credential, oauthCredential,
} }
const createUpdateOps = [ const createUpdateOps = [
@@ -890,7 +901,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'HubSpot access token' }, oauthCredential: { type: 'string', description: 'HubSpot access token' },
contactId: { type: 'string', description: 'Contact ID or email' }, contactId: { type: 'string', description: 'Contact ID or email' },
companyId: { type: 'string', description: 'Company ID or domain' }, companyId: { type: 'string', description: 'Company ID or domain' },
idProperty: { type: 'string', description: 'Property name to use as unique identifier' }, idProperty: { type: 'string', description: 'Property name to use as unique identifier' },

View File

@@ -60,6 +60,8 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
id: 'credential', id: 'credential',
title: 'Jira Account', title: 'Jira Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'jira', serviceId: 'jira',
requiredScopes: [ requiredScopes: [
@@ -96,6 +98,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
], ],
placeholder: 'Select Jira account', placeholder: 'Select Jira account',
}, },
{
id: 'manualCredential',
title: 'Jira Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Project selector (basic mode) // Project selector (basic mode)
{ {
id: 'projectId', id: 'projectId',
@@ -789,14 +800,14 @@ Return ONLY the comment text - no explanations.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, projectId, issueKey, ...rest } = params const { oauthCredential, projectId, issueKey, ...rest } = params
// Use canonical param IDs (raw subBlock IDs are deleted after serialization) // Use canonical param IDs (raw subBlock IDs are deleted after serialization)
const effectiveProjectId = projectId ? String(projectId).trim() : '' const effectiveProjectId = projectId ? String(projectId).trim() : ''
const effectiveIssueKey = issueKey ? String(issueKey).trim() : '' const effectiveIssueKey = issueKey ? String(issueKey).trim() : ''
const baseParams = { const baseParams = {
credential, oauthCredential,
domain: params.domain, domain: params.domain,
} }
@@ -1049,7 +1060,7 @@ Return ONLY the comment text - no explanations.`,
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Jira domain' }, domain: { type: 'string', description: 'Jira domain' },
credential: { type: 'string', description: 'Jira access token' }, oauthCredential: { type: 'string', description: 'Jira access token' },
issueKey: { type: 'string', description: 'Issue key identifier (canonical param)' }, issueKey: { type: 'string', description: 'Issue key identifier (canonical param)' },
projectId: { type: 'string', description: 'Project identifier (canonical param)' }, projectId: { type: 'string', description: 'Project identifier (canonical param)' },
// Update/Write operation inputs // Update/Write operation inputs

View File

@@ -55,6 +55,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
id: 'credential', id: 'credential',
title: 'Jira Account', title: 'Jira Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'jira', serviceId: 'jira',
requiredScopes: [ requiredScopes: [
@@ -95,6 +97,15 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
], ],
placeholder: 'Select Jira account', placeholder: 'Select Jira account',
}, },
{
id: 'manualCredential',
title: 'Jira Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'serviceDeskId', id: 'serviceDeskId',
title: 'Service Desk ID', title: 'Service Desk ID',
@@ -493,7 +504,7 @@ Return ONLY the comment text - no explanations.`,
}, },
params: (params) => { params: (params) => {
const baseParams = { const baseParams = {
credential: params.credential, oauthCredential: params.oauthCredential,
domain: params.domain, domain: params.domain,
} }
@@ -740,7 +751,7 @@ Return ONLY the comment text - no explanations.`,
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Jira domain' }, domain: { type: 'string', description: 'Jira domain' },
credential: { type: 'string', description: 'Jira Service Management access token' }, oauthCredential: { type: 'string', description: 'Jira Service Management access token' },
serviceDeskId: { type: 'string', description: 'Service desk ID' }, serviceDeskId: { type: 'string', description: 'Service desk ID' },
requestTypeId: { type: 'string', description: 'Request type ID' }, requestTypeId: { type: 'string', description: 'Request type ID' },
issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, issueIdOrKey: { type: 'string', description: 'Issue ID or key' },

View File

@@ -129,11 +129,22 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
id: 'credential', id: 'credential',
title: 'Linear Account', title: 'Linear Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'linear', serviceId: 'linear',
requiredScopes: ['read', 'write'], requiredScopes: ['read', 'write'],
placeholder: 'Select Linear account', placeholder: 'Select Linear account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Linear Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Team selector (for most operations) // Team selector (for most operations)
{ {
id: 'teamId', id: 'teamId',
@@ -1504,7 +1515,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Base params that most operations need // Base params that most operations need
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential: params.credential, oauthCredential: params.oauthCredential,
} }
// Operation-specific param mapping // Operation-specific param mapping
@@ -2323,7 +2334,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Linear access token' }, oauthCredential: { type: 'string', description: 'Linear access token' },
teamId: { type: 'string', description: 'Linear team identifier (canonical param)' }, teamId: { type: 'string', description: 'Linear team identifier (canonical param)' },
projectId: { type: 'string', description: 'Linear project identifier (canonical param)' }, projectId: { type: 'string', description: 'Linear project identifier (canonical param)' },
issueId: { type: 'string', description: 'Issue identifier' }, issueId: { type: 'string', description: 'Issue identifier' },

View File

@@ -33,10 +33,21 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
title: 'LinkedIn Account', title: 'LinkedIn Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'linkedin', serviceId: 'linkedin',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['profile', 'openid', 'email', 'w_member_social'], requiredScopes: ['profile', 'openid', 'email', 'w_member_social'],
placeholder: 'Select LinkedIn account', placeholder: 'Select LinkedIn account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'LinkedIn Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Share Post specific fields // Share Post specific fields
{ {
@@ -80,25 +91,25 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
}, },
params: (inputs) => { params: (inputs) => {
const operation = inputs.operation || 'share_post' const operation = inputs.operation || 'share_post'
const { credential, ...rest } = inputs const { oauthCredential, ...rest } = inputs
if (operation === 'get_profile') { if (operation === 'get_profile') {
return { return {
accessToken: credential, accessToken: oauthCredential,
} }
} }
return { return {
text: rest.text, text: rest.text,
visibility: rest.visibility || 'PUBLIC', visibility: rest.visibility || 'PUBLIC',
accessToken: credential, accessToken: oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'LinkedIn access token' }, oauthCredential: { type: 'string', description: 'LinkedIn access token' },
text: { type: 'string', description: 'Post text content' }, text: { type: 'string', description: 'Post text content' },
visibility: { type: 'string', description: 'Post visibility (PUBLIC or CONNECTIONS)' }, visibility: { type: 'string', description: 'Post visibility (PUBLIC or CONNECTIONS)' },
}, },

View File

@@ -36,6 +36,8 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel', serviceId: 'microsoft-excel',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -48,6 +50,15 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
title: 'Select Sheet', title: 'Select Sheet',
@@ -241,7 +252,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, values, spreadsheetId, tableName, worksheetName, ...rest } = params const { oauthCredential, values, spreadsheetId, tableName, worksheetName, ...rest } = params
// Use canonical param ID (raw subBlock IDs are deleted after serialization) // Use canonical param ID (raw subBlock IDs are deleted after serialization)
const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : '' const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : ''
@@ -269,7 +280,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
values: parsedValues, values: parsedValues,
credential, oauthCredential,
} }
if (params.operation === 'table_add') { if (params.operation === 'table_add') {
@@ -292,7 +303,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Microsoft Excel access token' }, oauthCredential: { type: 'string', description: 'Microsoft Excel access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
range: { type: 'string', description: 'Cell range' }, range: { type: 'string', description: 'Cell range' },
tableName: { type: 'string', description: 'Table name' }, tableName: { type: 'string', description: 'Table name' },
@@ -351,6 +362,8 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel', serviceId: 'microsoft-excel',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -363,6 +376,15 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector (basic mode) // Spreadsheet Selector (basic mode)
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -497,7 +519,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
fallbackToolId: 'microsoft_excel_read_v2', fallbackToolId: 'microsoft_excel_read_v2',
}), }),
params: (params) => { params: (params) => {
const { credential, values, spreadsheetId, sheetName, cellRange, ...rest } = params const { oauthCredential, values, spreadsheetId, sheetName, cellRange, ...rest } = params
const parsedValues = values ? JSON.parse(values as string) : undefined const parsedValues = values ? JSON.parse(values as string) : undefined
@@ -519,14 +541,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sheetName: effectiveSheetName, sheetName: effectiveSheetName,
cellRange: cellRange ? (cellRange as string).trim() : undefined, cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues, values: parsedValues,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Microsoft Excel access token' }, oauthCredential: { type: 'string', description: 'Microsoft Excel access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' }, sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },

View File

@@ -4,7 +4,7 @@ import { AuthMode } from '@/blocks/types'
import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types' import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types'
interface MicrosoftPlannerBlockParams { interface MicrosoftPlannerBlockParams {
credential: string oauthCredential: string
accessToken?: string accessToken?: string
planId?: string planId?: string
taskId?: string taskId?: string
@@ -61,6 +61,8 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-planner', serviceId: 'microsoft-planner',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -73,6 +75,14 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
], ],
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
// Plan ID - for various operations // Plan ID - for various operations
{ {
@@ -350,7 +360,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
groupId, groupId,
planId, planId,
@@ -375,7 +385,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
const baseParams: MicrosoftPlannerBlockParams = { const baseParams: MicrosoftPlannerBlockParams = {
...rest, ...rest,
credential, oauthCredential,
} }
// Handle different task ID fields based on operation // Handle different task ID fields based on operation
@@ -560,7 +570,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Microsoft account credential' }, oauthCredential: { type: 'string', description: 'Microsoft account credential' },
groupId: { type: 'string', description: 'Microsoft 365 group ID' }, groupId: { type: 'string', description: 'Microsoft 365 group ID' },
planId: { type: 'string', description: 'Plan ID' }, planId: { type: 'string', description: 'Plan ID' },
readTaskId: { type: 'string', description: 'Task ID for read operation' }, readTaskId: { type: 'string', description: 'Task ID for read operation' },

View File

@@ -44,6 +44,8 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-teams', serviceId: 'microsoft-teams',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -70,6 +72,15 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'teamSelector', id: 'teamSelector',
title: 'Select Team', title: 'Select Team',
@@ -321,7 +332,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
teamId, // Canonical param from teamSelector (basic) or manualTeamId (advanced) teamId, // Canonical param from teamSelector (basic) or manualTeamId (advanced)
chatId, // Canonical param from chatSelector (basic) or manualChatId (advanced) chatId, // Canonical param from chatSelector (basic) or manualChatId (advanced)
@@ -339,7 +350,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
...rest, ...rest,
credential, oauthCredential,
} }
if ((operation === 'read_chat' || operation === 'read_channel') && includeAttachments) { if ((operation === 'read_chat' || operation === 'read_channel') && includeAttachments) {
@@ -419,7 +430,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Microsoft Teams access token' }, oauthCredential: { type: 'string', description: 'Microsoft Teams access token' },
messageId: { messageId: {
type: 'string', type: 'string',
description: 'Message identifier for update/delete/reply/reaction operations', description: 'Message identifier for update/delete/reply/reaction operations',

View File

@@ -38,10 +38,21 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
id: 'credential', id: 'credential',
title: 'Notion Account', title: 'Notion Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'notion', serviceId: 'notion',
placeholder: 'Select Notion account', placeholder: 'Select Notion account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Notion Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Read/Write operation - Page ID // Read/Write operation - Page ID
{ {
id: 'pageId', id: 'pageId',
@@ -302,7 +313,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation, properties, filter, sorts, ...rest } = params const { oauthCredential, operation, properties, filter, sorts, ...rest } = params
// Parse properties from JSON string for create/add operations // Parse properties from JSON string for create/add operations
let parsedProperties let parsedProperties
@@ -351,7 +362,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
return { return {
...rest, ...rest,
credential, oauthCredential,
...(parsedProperties ? { properties: parsedProperties } : {}), ...(parsedProperties ? { properties: parsedProperties } : {}),
...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}), ...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}),
...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}), ...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}),
@@ -361,7 +372,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Notion access token' }, oauthCredential: { type: 'string', description: 'Notion access token' },
pageId: { type: 'string', description: 'Page identifier' }, pageId: { type: 'string', description: 'Page identifier' },
content: { type: 'string', description: 'Page content' }, content: { type: 'string', description: 'Page content' },
// Create page inputs // Create page inputs

View File

@@ -39,6 +39,8 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'onedrive', serviceId: 'onedrive',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -50,6 +52,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
], ],
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
// Create File Fields // Create File Fields
{ {
id: 'fileName', id: 'fileName',
@@ -355,7 +365,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId, uploadFolderId,
createFolderParentId, createFolderParentId,
@@ -405,7 +415,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
} }
return { return {
credential, oauthCredential,
...rest, ...rest,
values: normalizedValues, values: normalizedValues,
file: normalizedFile, file: normalizedFile,
@@ -420,7 +430,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Microsoft account credential' }, oauthCredential: { type: 'string', description: 'Microsoft account credential' },
// Upload and Create operation inputs // Upload and Create operation inputs
fileName: { type: 'string', description: 'File name' }, fileName: { type: 'string', description: 'File name' },
file: { type: 'json', description: 'File to upload (UserFile object)' }, file: { type: 'json', description: 'File to upload (UserFile object)' },

View File

@@ -39,6 +39,8 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'outlook', serviceId: 'outlook',
requiredScopes: [ requiredScopes: [
'Mail.ReadWrite', 'Mail.ReadWrite',
@@ -53,6 +55,15 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'to', id: 'to',
title: 'To', title: 'To',
@@ -326,7 +337,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
folder, folder,
destinationId, destinationId,
copyDestinationId, copyDestinationId,
@@ -385,14 +396,14 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
return { return {
...rest, ...rest,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Outlook access token' }, oauthCredential: { type: 'string', description: 'Outlook access token' },
// Send operation inputs // Send operation inputs
to: { type: 'string', description: 'Recipient email address' }, to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' }, subject: { type: 'string', description: 'Email subject' },

View File

@@ -45,6 +45,8 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
id: 'credential', id: 'credential',
title: 'Pipedrive Account', title: 'Pipedrive Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'pipedrive', serviceId: 'pipedrive',
requiredScopes: [ requiredScopes: [
'base', 'base',
@@ -58,6 +60,15 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
placeholder: 'Select Pipedrive account', placeholder: 'Select Pipedrive account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Pipedrive Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'status', id: 'status',
title: 'Status', title: 'Status',
@@ -746,10 +757,10 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation, ...rest } = params const { oauthCredential, operation, ...rest } = params
const cleanParams: Record<string, any> = { const cleanParams: Record<string, any> = {
credential, oauthCredential,
} }
Object.entries(rest).forEach(([key, value]) => { Object.entries(rest).forEach(([key, value]) => {
@@ -764,7 +775,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Pipedrive access token' }, oauthCredential: { type: 'string', description: 'Pipedrive access token' },
deal_id: { type: 'string', description: 'Deal ID' }, deal_id: { type: 'string', description: 'Deal ID' },
title: { type: 'string', description: 'Title' }, title: { type: 'string', description: 'Title' },
value: { type: 'string', description: 'Monetary value' }, value: { type: 'string', description: 'Monetary value' },

View File

@@ -43,6 +43,8 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
title: 'Reddit Account', title: 'Reddit Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'reddit', serviceId: 'reddit',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [ requiredScopes: [
'identity', 'identity',
'read', 'read',
@@ -64,6 +66,15 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
placeholder: 'Select Reddit account', placeholder: 'Select Reddit account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Reddit Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Common fields - appear for all actions // Common fields - appear for all actions
{ {
@@ -555,7 +566,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
}, },
params: (inputs) => { params: (inputs) => {
const operation = inputs.operation || 'get_posts' const operation = inputs.operation || 'get_posts'
const { credential, ...rest } = inputs const { oauthCredential, ...rest } = inputs
if (operation === 'get_comments') { if (operation === 'get_comments') {
return { return {
@@ -563,7 +574,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
subreddit: rest.subreddit, subreddit: rest.subreddit,
sort: rest.commentSort, sort: rest.commentSort,
limit: rest.commentLimit ? Number.parseInt(rest.commentLimit) : undefined, limit: rest.commentLimit ? Number.parseInt(rest.commentLimit) : undefined,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -572,7 +583,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
subreddit: rest.subreddit, subreddit: rest.subreddit,
time: rest.controversialTime, time: rest.controversialTime,
limit: rest.controversialLimit ? Number.parseInt(rest.controversialLimit) : undefined, limit: rest.controversialLimit ? Number.parseInt(rest.controversialLimit) : undefined,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -583,7 +594,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
sort: rest.searchSort, sort: rest.searchSort,
time: rest.searchTime, time: rest.searchTime,
limit: rest.searchLimit ? Number.parseInt(rest.searchLimit) : undefined, limit: rest.searchLimit ? Number.parseInt(rest.searchLimit) : undefined,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -595,7 +606,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
url: rest.postType === 'link' ? rest.url : undefined, url: rest.postType === 'link' ? rest.url : undefined,
nsfw: rest.nsfw === 'true', nsfw: rest.nsfw === 'true',
spoiler: rest.spoiler === 'true', spoiler: rest.spoiler === 'true',
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -603,7 +614,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
id: rest.voteId, id: rest.voteId,
dir: Number.parseInt(rest.voteDirection), dir: Number.parseInt(rest.voteDirection),
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -611,14 +622,14 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
id: rest.saveId, id: rest.saveId,
category: rest.saveCategory, category: rest.saveCategory,
credential: credential, oauthCredential: oauthCredential,
} }
} }
if (operation === 'unsave') { if (operation === 'unsave') {
return { return {
id: rest.saveId, id: rest.saveId,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -626,7 +637,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
parent_id: rest.replyParentId, parent_id: rest.replyParentId,
text: rest.replyText, text: rest.replyText,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -634,14 +645,14 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
thing_id: rest.editThingId, thing_id: rest.editThingId,
text: rest.editText, text: rest.editText,
credential: credential, oauthCredential: oauthCredential,
} }
} }
if (operation === 'delete') { if (operation === 'delete') {
return { return {
id: rest.deleteId, id: rest.deleteId,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -649,7 +660,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
subreddit: rest.subscribeSubreddit, subreddit: rest.subscribeSubreddit,
action: rest.subscribeAction, action: rest.subscribeAction,
credential: credential, oauthCredential: oauthCredential,
} }
} }
@@ -658,14 +669,14 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
sort: rest.sort, sort: rest.sort,
limit: rest.limit ? Number.parseInt(rest.limit) : undefined, limit: rest.limit ? Number.parseInt(rest.limit) : undefined,
time: rest.sort === 'top' ? rest.time : undefined, time: rest.sort === 'top' ? rest.time : undefined,
credential: credential, oauthCredential: oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Reddit access token' }, oauthCredential: { type: 'string', description: 'Reddit access token' },
subreddit: { type: 'string', description: 'Subreddit name' }, subreddit: { type: 'string', description: 'Subreddit name' },
sort: { type: 'string', description: 'Sort order' }, sort: { type: 'string', description: 'Sort order' },
time: { type: 'string', description: 'Time filter' }, time: { type: 'string', description: 'Time filter' },

View File

@@ -1,12 +1,9 @@
import { ConnectIcon } from '@/components/icons' import { ConnectIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions,
getProviderCredentialSubBlocks,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import type { ProviderId } from '@/providers/types' import type { ProviderId } from '@/providers/types'
import { getBaseModelProviders } from '@/providers/utils' import { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import type { ToolResponse } from '@/tools/types' import type { ToolResponse } from '@/tools/types'
interface RouterResponse extends ToolResponse { interface RouterResponse extends ToolResponse {
@@ -137,6 +134,25 @@ Respond with a JSON object containing:
- reasoning: A brief explanation (1-2 sentences) of why you chose this route` - reasoning: A brief explanation (1-2 sentences) of why you chose this route`
} }
/**
* Helper to get model options for both router versions.
*/
const getModelOptions = () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/** /**
* Legacy Router Block (block-based routing). * Legacy Router Block (block-based routing).
* Hidden from toolbar but still supported for existing workflows. * Hidden from toolbar but still supported for existing workflows.

View File

@@ -62,11 +62,22 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
id: 'credential', id: 'credential',
title: 'Salesforce Account', title: 'Salesforce Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'salesforce', serviceId: 'salesforce',
requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'], requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'],
placeholder: 'Select Salesforce account', placeholder: 'Select Salesforce account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Salesforce Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Common fields for GET operations // Common fields for GET operations
{ {
id: 'fields', id: 'fields',
@@ -614,8 +625,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation, ...rest } = params const { oauthCredential, operation, ...rest } = params
const cleanParams: Record<string, any> = { credential } const cleanParams: Record<string, any> = { oauthCredential }
Object.entries(rest).forEach(([key, value]) => { Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== null && value !== '') {
cleanParams[key] = value cleanParams[key] = value
@@ -627,7 +638,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Salesforce credential' }, oauthCredential: { type: 'string', description: 'Salesforce credential' },
}, },
outputs: { outputs: {
success: { type: 'boolean', description: 'Operation success status' }, success: { type: 'boolean', description: 'Operation success status' },

View File

@@ -38,6 +38,8 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'sharepoint', serviceId: 'sharepoint',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -50,6 +52,14 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
], ],
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
{ {
id: 'siteSelector', id: 'siteSelector',
@@ -403,7 +413,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, siteId, mimeType, ...rest } = params const { oauthCredential, siteId, mimeType, ...rest } = params
// siteId is the canonical param from siteSelector (basic) or manualSiteId (advanced) // siteId is the canonical param from siteSelector (basic) or manualSiteId (advanced)
const effectiveSiteId = siteId ? String(siteId).trim() : '' const effectiveSiteId = siteId ? String(siteId).trim() : ''
@@ -461,7 +471,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
// Handle file upload files parameter using canonical param // Handle file upload files parameter using canonical param
const normalizedFiles = normalizeFileInput(files) const normalizedFiles = normalizeFileInput(files)
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential, oauthCredential,
siteId: effectiveSiteId || undefined, siteId: effectiveSiteId || undefined,
pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined, pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined,
mimeType: mimeType, mimeType: mimeType,
@@ -487,7 +497,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Microsoft account credential' }, oauthCredential: { type: 'string', description: 'Microsoft account credential' },
pageName: { type: 'string', description: 'Page name' }, pageName: { type: 'string', description: 'Page name' },
columnDefinitions: { columnDefinitions: {
type: 'string', type: 'string',

View File

@@ -61,6 +61,8 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
title: 'Shopify Account', title: 'Shopify Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'shopify', serviceId: 'shopify',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [ requiredScopes: [
'write_products', 'write_products',
'write_orders', 'write_orders',
@@ -72,6 +74,15 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
placeholder: 'Select Shopify account', placeholder: 'Select Shopify account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Shopify Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'shopDomain', id: 'shopDomain',
title: 'Shop Domain', title: 'Shop Domain',
@@ -527,7 +538,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
}, },
params: (params) => { params: (params) => {
const baseParams: Record<string, unknown> = { const baseParams: Record<string, unknown> = {
credential: params.credential, oauthCredential: params.oauthCredential,
shopDomain: params.shopDomain?.trim(), shopDomain: params.shopDomain?.trim(),
} }
@@ -774,7 +785,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Shopify access token' }, oauthCredential: { type: 'string', description: 'Shopify access token' },
shopDomain: { type: 'string', description: 'Shopify store domain' }, shopDomain: { type: 'string', description: 'Shopify store domain' },
// Product inputs // Product inputs
productId: { type: 'string', description: 'Product ID' }, productId: { type: 'string', description: 'Product ID' },

View File

@@ -69,6 +69,8 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'credential', id: 'credential',
title: 'Slack Account', title: 'Slack Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'slack', serviceId: 'slack',
requiredScopes: [ requiredScopes: [
'channels:read', 'channels:read',
@@ -94,6 +96,20 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
}, },
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Slack Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
dependsOn: ['authMethod'],
condition: {
field: 'authMethod',
value: 'oauth',
},
required: true,
},
{ {
id: 'botToken', id: 'botToken',
title: 'Bot Token', title: 'Bot Token',
@@ -547,7 +563,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
authMethod, authMethod,
botToken, botToken,
operation, operation,
@@ -597,7 +613,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
baseParams.accessToken = botToken baseParams.accessToken = botToken
} else { } else {
// Default to OAuth // Default to OAuth
baseParams.credential = credential baseParams.credential = oauthCredential
} }
switch (operation) { switch (operation) {
@@ -701,7 +717,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
authMethod: { type: 'string', description: 'Authentication method' }, authMethod: { type: 'string', description: 'Authentication method' },
destinationType: { type: 'string', description: 'Destination type (channel or dm)' }, destinationType: { type: 'string', description: 'Destination type (channel or dm)' },
credential: { type: 'string', description: 'Slack access token' }, oauthCredential: { type: 'string', description: 'Slack access token' },
botToken: { type: 'string', description: 'Bot token' }, botToken: { type: 'string', description: 'Bot token' },
channel: { type: 'string', description: 'Channel identifier (canonical param)' }, channel: { type: 'string', description: 'Channel identifier (canonical param)' },
dmUserId: { type: 'string', description: 'User ID for DM recipient (canonical param)' }, dmUserId: { type: 'string', description: 'User ID for DM recipient (canonical param)' },

View File

@@ -160,6 +160,17 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
title: 'Spotify Account', title: 'Spotify Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'spotify', serviceId: 'spotify',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true,
},
{
id: 'manualCredential',
title: 'Spotify Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true, required: true,
}, },
@@ -796,7 +807,7 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Spotify OAuth credential' }, oauthCredential: { type: 'string', description: 'Spotify OAuth credential' },
// Search // Search
query: { type: 'string', description: 'Search query' }, query: { type: 'string', description: 'Search query' },
type: { type: 'string', description: 'Search type' }, type: { type: 'string', description: 'Search type' },

View File

@@ -1,10 +1,8 @@
import { TranslateIcon } from '@/components/icons' import { TranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types' import { AuthMode, type BlockConfig } from '@/blocks/types'
import { import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
getModelOptions, import { getProviderIcon } from '@/providers/utils'
getProviderCredentialSubBlocks, import { useProvidersStore } from '@/stores/providers/store'
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
const getTranslationPrompt = (targetLanguage: string) => const getTranslationPrompt = (targetLanguage: string) =>
`Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.` `Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.`
@@ -40,7 +38,18 @@ export const TranslateBlock: BlockConfig = {
type: 'combobox', type: 'combobox',
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
options: getModelOptions, options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels]))
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
}, },
...getProviderCredentialSubBlocks(), ...getProviderCredentialSubBlocks(),
{ {

View File

@@ -42,10 +42,21 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
title: 'Trello Account', title: 'Trello Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'trello', serviceId: 'trello',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['read', 'write'], requiredScopes: ['read', 'write'],
placeholder: 'Select Trello account', placeholder: 'Select Trello account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Trello Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'boardId', id: 'boardId',
@@ -394,7 +405,7 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Trello operation to perform' }, operation: { type: 'string', description: 'Trello operation to perform' },
credential: { type: 'string', description: 'Trello OAuth credential' }, oauthCredential: { type: 'string', description: 'Trello OAuth credential' },
boardId: { type: 'string', description: 'Board ID' }, boardId: { type: 'string', description: 'Board ID' },
listId: { type: 'string', description: 'List ID' }, listId: { type: 'string', description: 'List ID' },
cardId: { type: 'string', description: 'Card ID' }, cardId: { type: 'string', description: 'Card ID' },

View File

@@ -33,11 +33,22 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
id: 'credential', id: 'credential',
title: 'Wealthbox Account', title: 'Wealthbox Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wealthbox', serviceId: 'wealthbox',
requiredScopes: ['login', 'data'], requiredScopes: ['login', 'data'],
placeholder: 'Select Wealthbox account', placeholder: 'Select Wealthbox account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Wealthbox Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'noteId', id: 'noteId',
title: 'Note ID', title: 'Note ID',
@@ -169,14 +180,14 @@ Return ONLY the date/time string - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation, contactId, taskId, ...rest } = params const { oauthCredential, operation, contactId, taskId, ...rest } = params
// contactId is the canonical param for both basic (file-selector) and advanced (manualContactId) modes // contactId is the canonical param for both basic (file-selector) and advanced (manualContactId) modes
const effectiveContactId = contactId ? String(contactId).trim() : '' const effectiveContactId = contactId ? String(contactId).trim() : ''
const baseParams = { const baseParams = {
...rest, ...rest,
credential, credential: oauthCredential,
} }
if (operation === 'read_note' || operation === 'write_note') { if (operation === 'read_note' || operation === 'write_note') {
@@ -220,7 +231,7 @@ Return ONLY the date/time string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Wealthbox access token' }, oauthCredential: { type: 'string', description: 'Wealthbox access token' },
noteId: { type: 'string', description: 'Note identifier' }, noteId: { type: 'string', description: 'Note identifier' },
contactId: { type: 'string', description: 'Contact identifier' }, contactId: { type: 'string', description: 'Contact identifier' },
taskId: { type: 'string', description: 'Task identifier' }, taskId: { type: 'string', description: 'Task identifier' },

View File

@@ -34,11 +34,22 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
id: 'credential', id: 'credential',
title: 'Webflow Account', title: 'Webflow Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'webflow', serviceId: 'webflow',
requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'], requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'],
placeholder: 'Select Webflow account', placeholder: 'Select Webflow account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Webflow Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'siteSelector', id: 'siteSelector',
title: 'Site', title: 'Site',
@@ -156,7 +167,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
fieldData, fieldData,
siteId, // Canonical param from siteSelector (basic) or manualSiteId (advanced) siteId, // Canonical param from siteSelector (basic) or manualSiteId (advanced)
collectionId, // Canonical param from collectionSelector (basic) or manualCollectionId (advanced) collectionId, // Canonical param from collectionSelector (basic) or manualCollectionId (advanced)
@@ -178,7 +189,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
const effectiveItemId = itemId ? String(itemId).trim() : '' const effectiveItemId = itemId ? String(itemId).trim() : ''
const baseParams = { const baseParams = {
credential, credential: oauthCredential,
siteId: effectiveSiteId, siteId: effectiveSiteId,
collectionId: effectiveCollectionId, collectionId: effectiveCollectionId,
...rest, ...rest,
@@ -203,7 +214,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Webflow OAuth access token' }, oauthCredential: { type: 'string', description: 'Webflow OAuth access token' },
siteId: { type: 'string', description: 'Webflow site identifier' }, siteId: { type: 'string', description: 'Webflow site identifier' },
collectionId: { type: 'string', description: 'Webflow collection identifier' }, collectionId: { type: 'string', description: 'Webflow collection identifier' },
itemId: { type: 'string', description: 'Item identifier' }, itemId: { type: 'string', description: 'Item identifier' },

View File

@@ -65,11 +65,22 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
id: 'credential', id: 'credential',
title: 'WordPress Account', title: 'WordPress Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wordpress', serviceId: 'wordpress',
requiredScopes: ['global'], requiredScopes: ['global'],
placeholder: 'Select WordPress account', placeholder: 'Select WordPress account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'WordPress Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Site ID for WordPress.com (required for OAuth) // Site ID for WordPress.com (required for OAuth)
{ {
@@ -667,7 +678,7 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
params: (params) => { params: (params) => {
// OAuth authentication for WordPress.com // OAuth authentication for WordPress.com
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential: params.credential, credential: params.oauthCredential,
siteId: params.siteId, siteId: params.siteId,
} }
@@ -890,6 +901,7 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'WordPress OAuth credential' },
siteId: { type: 'string', description: 'WordPress.com site ID or domain' }, siteId: { type: 'string', description: 'WordPress.com site ID or domain' },
// Post inputs // Post inputs
postId: { type: 'number', description: 'Post ID' }, postId: { type: 'number', description: 'Post ID' },

View File

@@ -32,9 +32,19 @@ export const XBlock: BlockConfig<XResponse> = {
title: 'X Account', title: 'X Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'x', serviceId: 'x',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], requiredScopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
placeholder: 'Select X account', placeholder: 'Select X account',
}, },
{
id: 'manualCredential',
title: 'X Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
{ {
id: 'text', id: 'text',
title: 'Tweet Text', title: 'Tweet Text',
@@ -171,10 +181,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, ...rest } = params const { oauthCredential, ...rest } = params
const parsedParams: Record<string, any> = { const parsedParams: Record<string, any> = {
credential: credential, credential: oauthCredential,
} }
Object.keys(rest).forEach((key) => { Object.keys(rest).forEach((key) => {
@@ -200,7 +210,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'X account credential' }, oauthCredential: { type: 'string', description: 'X account credential' },
text: { type: 'string', description: 'Tweet text content' }, text: { type: 'string', description: 'Tweet text content' },
replyTo: { type: 'string', description: 'Reply to tweet ID' }, replyTo: { type: 'string', description: 'Reply to tweet ID' },
mediaIds: { type: 'string', description: 'Media identifiers' }, mediaIds: { type: 'string', description: 'Media identifiers' },

Some files were not shown because too many files have changed in this diff Show More