Compare commits

..

21 Commits

Author SHA1 Message Date
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
121 changed files with 18160 additions and 979 deletions

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard <BlockInfoCard
type="google_books" type="google_books"
color="#E0E0E0" color="#FFFFFF"
/> />
## Usage Instructions ## Usage Instructions

View File

@@ -71,7 +71,6 @@ Retrieve an object from an AWS S3 bucket
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | Your AWS Access Key ID | | `accessKeyId` | string | Yes | Your AWS Access Key ID |
| `secretAccessKey` | string | Yes | Your AWS Secret Access Key | | `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
| `region` | string | No | Optional region override when URL does not include region \(e.g., us-east-1, eu-west-1\) |
| `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket.s3.region.amazonaws.com/path/to/file\) | | `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket.s3.region.amazonaws.com/path/to/file\) |
#### Output #### Output

View File

@@ -79,7 +79,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| `channel` | string | No | Slack channel ID \(e.g., C1234567890\) | | `channel` | string | No | Slack channel ID \(e.g., C1234567890\) |
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) | | `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) | | `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) | | `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) |
| `files` | file[] | No | Files to attach to the message | | `files` | file[] | No | Files to attach to the message |
#### Output #### Output

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,197 @@
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'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireAdminMembership(credentialId: string, userId: string) {
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 })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ members: [] }, { status: 200 })
}
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 requireAdminMembership(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 requireAdminMembership(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,
status: credentialMember.status,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId)
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
if (target.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 remove the last admin' }, { status: 400 })
}
}
await db
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
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,258 @@
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(),
accountId: z.string().trim().min(1).optional(),
})
.strict()
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.accountId !== 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,85 @@
import { db } from '@sim/db'
import { 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'
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 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

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

@@ -238,11 +238,6 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg
finalSystemPrompt += currentTimeContext finalSystemPrompt += currentTimeContext
} }
if (generationType === 'cron-expression') {
finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.'
}
if (generationType === 'json-object') { if (generationType === 'json-object') {
finalSystemPrompt += finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.' '\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'

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

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

@@ -3,10 +3,12 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' 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,9 +20,9 @@ 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'
@@ -46,6 +48,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 +100,70 @@ 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,
selectedId,
credentialsLoading,
])
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 +205,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 +271,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 +292,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 +384,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 +396,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 +421,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 +443,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, selectedId, credentialsLoading])
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,10 +1,9 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface ToolSubBlockRendererProps { interface ToolSubBlockRendererProps {
blockId: string blockId: string
@@ -45,43 +44,53 @@ export function ToolSubBlockRenderer({
canonicalToggle, canonicalToggle,
}: ToolSubBlockRendererProps) { }: ToolSubBlockRendererProps) {
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}` const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
const toolParamValue = toolParams?.[effectiveParamId] ?? '' const toolParamValue = toolParams?.[effectiveParamId] ?? ''
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type) const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
const syncedRef = useRef<string | null>(null) const lastPushedToStoreRef = useRef<string | null>(null)
const onParamChangeRef = useRef(onParamChange) const lastPushedToParamsRef = useRef<string | null>(null)
onParamChangeRef.current = onParamChange
useEffect(() => { useEffect(() => {
const unsub = useSubBlockStore.subscribe((state, prevState) => { if (!toolParamValue && lastPushedToStoreRef.current === null) {
const wfId = useWorkflowRegistry.getState().activeWorkflowId lastPushedToStoreRef.current = toolParamValue
if (!wfId) return lastPushedToParamsRef.current = toolParamValue
const newVal = state.workflowValues[wfId]?.[blockId]?.[syntheticId] return
const oldVal = prevState.workflowValues[wfId]?.[blockId]?.[syntheticId]
if (newVal === oldVal) return
const stringified =
newVal == null ? '' : typeof newVal === 'string' ? newVal : JSON.stringify(newVal)
if (stringified === syncedRef.current) return
syncedRef.current = stringified
onParamChangeRef.current(toolIndex, effectiveParamId, stringified)
})
return unsub
}, [blockId, syntheticId, toolIndex, effectiveParamId])
useEffect(() => {
if (toolParamValue === syncedRef.current) return
syncedRef.current = toolParamValue
if (isObjectType && toolParamValue) {
try {
const parsed = JSON.parse(toolParamValue)
if (typeof parsed === 'object' && parsed !== null) {
useSubBlockStore.getState().setValue(blockId, syntheticId, parsed)
return
}
} catch {}
} }
useSubBlockStore.getState().setValue(blockId, syntheticId, toolParamValue) if (toolParamValue !== lastPushedToStoreRef.current) {
}, [toolParamValue, blockId, syntheticId, isObjectType]) lastPushedToStoreRef.current = toolParamValue
lastPushedToParamsRef.current = toolParamValue
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) {
try {
const parsed = JSON.parse(toolParamValue)
if (typeof parsed === 'object' && parsed !== null) {
setStoreValue(parsed)
return
}
} catch {
// Not valid JSON — fall through to set as string
}
}
setStoreValue(toolParamValue)
}
}, [toolParamValue, setStoreValue, isObjectType])
useEffect(() => {
if (storeValue == null && lastPushedToParamsRef.current === null) return
const stringValue =
storeValue == null
? ''
: typeof storeValue === 'string'
? storeValue
: JSON.stringify(storeValue)
if (stringValue !== lastPushedToParamsRef.current) {
lastPushedToParamsRef.current = stringValue
lastPushedToStoreRef.current = stringValue
onParamChange(toolIndex, effectiveParamId, stringValue)
}
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
const visibility = subBlock.paramVisibility ?? 'user-or-llm' const visibility = subBlock.paramVisibility ?? 'user-or-llm'
const isOptionalForUser = visibility !== 'user-only' const isOptionalForUser = visibility !== 'user-only'

View File

@@ -1741,97 +1741,36 @@ export const ToolInput = memo(function ToolInput({
) : null ) : null
})()} })()}
{requiresOAuth && oauthConfig && (
<div className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Account <span className='ml-0.5'>*</span>
</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params?.credential || ''}
onChange={(value: string) =>
handleParamChange(toolIndex, 'credential', value)
}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={
toolBlock?.subBlocks?.find((sb) => sb.id === 'credential')
?.requiredScopes ||
getCanonicalScopesForProvider(oauthConfig.provider)
}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
</div>
</div>
)}
{(() => { {(() => {
const renderedElements: React.ReactNode[] = [] const renderedElements: React.ReactNode[] = []
const showOAuth =
requiresOAuth && oauthConfig && tool.params?.authMethod !== 'bot_token'
const renderOAuthAccount = (): React.ReactNode => {
if (!showOAuth || !oauthConfig) return null
const credentialSubBlock = toolBlock?.subBlocks?.find(
(s) => s.type === 'oauth-input'
)
return (
<div key='oauth-account' className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
{credentialSubBlock?.title || 'Account'}{' '}
<span className='ml-0.5'>*</span>
</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params?.credential || ''}
onChange={(value: string) =>
handleParamChange(toolIndex, 'credential', value)
}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={
credentialSubBlock?.requiredScopes ||
getCanonicalScopesForProvider(oauthConfig.provider)
}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
</div>
</div>
)
}
const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => {
const effectiveParamId = sb.id
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
const canonicalGroup = canonicalId
? toolCanonicalIndex?.groupsById[canonicalId]
: undefined
const hasCanonicalPair = isCanonicalPair(canonicalGroup)
const canonicalMode =
canonicalGroup && hasCanonicalPair
? resolveCanonicalMode(
canonicalGroup,
{ operation: tool.operation, ...tool.params },
toolScopedOverrides
)
: undefined
const canonicalToggleProp =
hasCanonicalPair && canonicalMode && canonicalId
? {
mode: canonicalMode,
onToggle: () => {
const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
blockId,
`${tool.type}:${canonicalId}`,
nextMode
)
},
}
: undefined
const sbWithTitle = sb.title
? sb
: { ...sb, title: formatParameterLabel(effectiveParamId) }
return (
<ToolSubBlockRenderer
key={sb.id}
blockId={blockId}
subBlockId={subBlockId}
toolIndex={toolIndex}
subBlock={sbWithTitle}
effectiveParamId={effectiveParamId}
toolParams={tool.params}
onParamChange={handleParamChange}
disabled={disabled}
canonicalToggle={canonicalToggleProp}
/>
)
}
if (useSubBlocks && displaySubBlocks.length > 0) { if (useSubBlocks && displaySubBlocks.length > 0) {
const allBlockSubBlocks = toolBlock?.subBlocks || []
const coveredParamIds = new Set( const coveredParamIds = new Set(
allBlockSubBlocks.flatMap((sb) => { displaySubBlocks.flatMap((sb) => {
const ids = [sb.id] const ids = [sb.id]
if (sb.canonicalParamId) ids.push(sb.canonicalParamId) if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
@@ -1846,45 +1785,57 @@ export const ToolInput = memo(function ToolInput({
}) })
) )
type RenderItem = displaySubBlocks.forEach((sb) => {
| { kind: 'subblock'; sb: BlockSubBlockConfig } const effectiveParamId = sb.id
| { kind: 'oauth' } const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
const canonicalGroup = canonicalId
? toolCanonicalIndex?.groupsById[canonicalId]
: undefined
const hasCanonicalPair = isCanonicalPair(canonicalGroup)
const canonicalMode =
canonicalGroup && hasCanonicalPair
? resolveCanonicalMode(
canonicalGroup,
{ operation: tool.operation, ...tool.params },
toolScopedOverrides
)
: undefined
const renderOrder: RenderItem[] = displaySubBlocks.map((sb) => ({ const canonicalToggleProp =
kind: 'subblock' as const, hasCanonicalPair && canonicalMode && canonicalId
sb, ? {
})) mode: canonicalMode,
onToggle: () => {
const nextMode =
canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
blockId,
`${tool.type}:${canonicalId}`,
nextMode
)
},
}
: undefined
if (showOAuth) { const sbWithTitle = sb.title
const credentialIdx = allBlockSubBlocks.findIndex( ? sb
(sb) => sb.type === 'oauth-input' : { ...sb, title: formatParameterLabel(effectiveParamId) }
renderedElements.push(
<ToolSubBlockRenderer
key={sb.id}
blockId={blockId}
subBlockId={subBlockId}
toolIndex={toolIndex}
subBlock={sbWithTitle}
effectiveParamId={effectiveParamId}
toolParams={tool.params}
onParamChange={handleParamChange}
disabled={disabled}
canonicalToggle={canonicalToggleProp}
/>
) )
if (credentialIdx >= 0) { })
const sbPositions = new Map(allBlockSubBlocks.map((sb, i) => [sb.id, i]))
const insertAt = renderOrder.findIndex(
(item) =>
item.kind === 'subblock' &&
(sbPositions.get(item.sb.id) ?? Number.POSITIVE_INFINITY) >
credentialIdx
)
if (insertAt === -1) {
renderOrder.push({ kind: 'oauth' })
} else {
renderOrder.splice(insertAt, 0, { kind: 'oauth' })
}
} else {
renderOrder.unshift({ kind: 'oauth' })
}
}
for (const item of renderOrder) {
if (item.kind === 'oauth') {
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
} else {
renderedElements.push(renderSubBlock(item.sb))
}
}
const uncoveredParams = displayParams.filter( const uncoveredParams = displayParams.filter(
(param) => (param) =>
@@ -1922,11 +1873,6 @@ export const ToolInput = memo(function ToolInput({
) )
} }
{
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
}
const filteredParams = displayParams.filter((param) => const filteredParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool) evaluateParameterCondition(param, tool)
) )

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

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

View File

@@ -134,7 +134,7 @@ function WorkspaceVariableRow({
<Trash /> <Trash />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content> <Tooltip.Content>Delete secret</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div> </div>
@@ -637,7 +637,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<Trash /> <Trash />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content> <Tooltip.Content>Delete secret</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div> </div>
@@ -811,7 +811,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
filteredWorkspaceEntries.length === 0 && filteredWorkspaceEntries.length === 0 &&
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && ( (envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'> <div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No environment variables found matching "{searchTerm}" No secrets found matching "{searchTerm}"
</div> </div>
)} )}
</> </>

View File

@@ -2,6 +2,7 @@ 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 { EnvironmentVariables } from './environment/environment'

View File

@@ -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,6 +78,7 @@ interface SettingsModalProps {
type SettingsSection = type SettingsSection =
| 'general' | 'general'
| 'credentials'
| 'environment' | 'environment'
| 'template-profile' | 'template-profile'
| 'integrations' | 'integrations'
@@ -156,11 +155,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' },
{ {
@@ -256,9 +254,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
} }
@@ -324,6 +319,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) { if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
return 'general' return 'general'
} }
if (activeSection === 'environment' || activeSection === 'integrations') {
return 'credentials'
}
return activeSection return activeSection
}, [activeSection]) }, [activeSection])
@@ -342,7 +340,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
(sectionId: SettingsSection) => { (sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return if (sectionId === effectiveActiveSection) return
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) { if (effectiveActiveSection === 'credentials' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return return
} }
@@ -370,7 +368,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
useEffect(() => { useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => { const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
setActiveSection(event.detail.tab) if (event.detail.tab === 'environment' || event.detail.tab === 'integrations') {
setActiveSection('credentials')
} else {
setActiveSection(event.detail.tab)
}
onOpenChange(true) onOpenChange(true)
} }
@@ -479,13 +481,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleDialogOpenChange = (newOpen: boolean) => { const handleDialogOpenChange = (newOpen: boolean) => {
if ( if (
!newOpen && !newOpen &&
effectiveActiveSection === 'environment' && effectiveActiveSection === 'credentials' &&
environmentBeforeLeaveHandler.current environmentBeforeLeaveHandler.current
) { ) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false)) environmentBeforeLeaveHandler.current(() => {
if (integrationsCloseHandler.current) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(false)
}
})
} else if ( } else if (
!newOpen && !newOpen &&
effectiveActiveSection === 'integrations' && effectiveActiveSection === 'credentials' &&
integrationsCloseHandler.current integrationsCloseHandler.current
) { ) {
integrationsCloseHandler.current(newOpen) integrationsCloseHandler.current(newOpen)
@@ -502,7 +510,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 +547,14 @@ 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}
registerCloseHandler={registerIntegrationsCloseHandler}
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler} 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

@@ -142,6 +142,8 @@ Return ONLY the JSON array.`,
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,
@@ -150,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',
@@ -748,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

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

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

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

@@ -122,25 +122,6 @@ export const ScheduleBlock: BlockConfig = {
required: true, required: true,
mode: 'trigger', mode: 'trigger',
condition: { field: 'scheduleType', value: 'custom' }, condition: { field: 'scheduleType', value: 'custom' },
wandConfig: {
enabled: true,
prompt: `You are an expert at writing cron expressions. Generate a valid cron expression based on the user's description.
Cron format: minute hour day-of-month month day-of-week
- minute: 0-59
- hour: 0-23
- day-of-month: 1-31
- month: 1-12
- day-of-week: 0-7 (0 and 7 are Sunday)
Special characters: * (any), , (list), - (range), / (step)
{context}
Return ONLY the cron expression, nothing else. No explanation, no backticks, no quotes.`,
placeholder: 'Describe your schedule (e.g., "every weekday at 9am")',
generationType: 'cron-expression',
},
}, },
{ {

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

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

View File

@@ -38,6 +38,8 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
title: 'Zoom Account', title: 'Zoom Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'zoom', serviceId: 'zoom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [ requiredScopes: [
'user:read:user', 'user:read:user',
'meeting:write:meeting', 'meeting:write:meeting',
@@ -54,6 +56,15 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
placeholder: 'Select Zoom account', placeholder: 'Select Zoom account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Zoom Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// User ID for create/list operations // User ID for create/list operations
{ {
id: 'userId', id: 'userId',
@@ -413,7 +424,7 @@ Return ONLY the date string - no explanations, no quotes, no extra text.`,
}, },
params: (params) => { params: (params) => {
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential: params.credential, credential: params.oauthCredential,
} }
switch (params.operation) { switch (params.operation) {
@@ -558,7 +569,7 @@ Return ONLY the date 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: 'Zoom access token' }, oauthCredential: { type: 'string', description: 'Zoom access token' },
userId: { type: 'string', description: 'User ID or email (use "me" for authenticated user)' }, userId: { type: 'string', description: 'User ID or email (use "me" for authenticated user)' },
meetingId: { type: 'string', description: 'Meeting ID' }, meetingId: { type: 'string', description: 'Meeting ID' },
topic: { type: 'string', description: 'Meeting topic' }, topic: { type: 'string', description: 'Meeting topic' },

View File

@@ -40,7 +40,6 @@ export type GenerationType =
| 'neo4j-parameters' | 'neo4j-parameters'
| 'timestamp' | 'timestamp'
| 'timezone' | 'timezone'
| 'cron-expression'
export type SubBlockType = export type SubBlockType =
| 'short-input' // Single line input | 'short-input' // Single line input

View File

@@ -205,10 +205,6 @@ export const CREDENTIAL_SET = {
PREFIX: 'credentialSet:', PREFIX: 'credentialSet:',
} as const } as const
export const CREDENTIAL = {
FOREIGN_LABEL: 'Saved by collaborator',
} as const
export function isCredentialSetValue(value: string | null | undefined): boolean { export function isCredentialSetValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX) return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
} }

View File

@@ -264,6 +264,7 @@ export class DAGExecutor {
executionId: this.contextExtensions.executionId, executionId: this.contextExtensions.executionId,
userId: this.contextExtensions.userId, userId: this.contextExtensions.userId,
isDeployedContext: this.contextExtensions.isDeployedContext, isDeployedContext: this.contextExtensions.isDeployedContext,
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
blockStates: state.getBlockStates(), blockStates: state.getBlockStates(),
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []), blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
metadata: { metadata: {

View File

@@ -16,6 +16,7 @@ export interface ExecutionMetadata {
useDraftState: boolean useDraftState: boolean
startTime: string startTime: string
isClientSession?: boolean isClientSession?: boolean
enforceCredentialAccess?: boolean
pendingBlocks?: string[] pendingBlocks?: string[]
resumeFromSnapshot?: boolean resumeFromSnapshot?: boolean
credentialAccountUserId?: string credentialAccountUserId?: string
@@ -80,6 +81,7 @@ export interface ContextExtensions {
selectedOutputs?: string[] selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }> edges?: Array<{ source: string; target: string }>
isDeployedContext?: boolean isDeployedContext?: boolean
enforceCredentialAccess?: boolean
isChildExecution?: boolean isChildExecution?: boolean
resumeFromSnapshot?: boolean resumeFromSnapshot?: boolean
resumePendingQueue?: string[] resumePendingQueue?: string[]

View File

@@ -336,6 +336,7 @@ export class AgentBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -74,6 +74,7 @@ export class ApiBlockHandler implements BlockHandler {
executionId: ctx.executionId, executionId: ctx.executionId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -50,6 +50,7 @@ export async function evaluateConditionExpression(
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -41,6 +41,7 @@ export class FunctionBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -68,6 +68,7 @@ export class GenericBlockHandler implements BlockHandler {
executionId: ctx.executionId, executionId: ctx.executionId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -607,6 +607,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
blockData: blockDataWithPause, blockData: blockDataWithPause,
blockNameMapping: blockNameMappingWithPause, blockNameMapping: blockNameMappingWithPause,

View File

@@ -123,6 +123,7 @@ export class WorkflowBlockHandler implements BlockHandler {
contextExtensions: { contextExtensions: {
isChildExecution: true, isChildExecution: true,
isDeployedContext: ctx.isDeployedContext === true, isDeployedContext: ctx.isDeployedContext === true,
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
executionId: ctx.executionId, executionId: ctx.executionId,

View File

@@ -168,6 +168,7 @@ export interface ExecutionContext {
executionId?: string executionId?: string
userId?: string userId?: string
isDeployedContext?: boolean isDeployedContext?: boolean
enforceCredentialAccess?: boolean
permissionConfig?: PermissionGroupConfig | null permissionConfig?: PermissionGroupConfig | null
permissionConfigLoaded?: boolean permissionConfigLoaded?: boolean

View File

@@ -0,0 +1,272 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { environmentKeys } from '@/hooks/queries/environment'
import { fetchJson } from '@/hooks/selectors/helpers'
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal'
export type WorkspaceCredentialRole = 'admin' | 'member'
export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked'
export interface WorkspaceCredential {
id: string
workspaceId: string
type: WorkspaceCredentialType
displayName: string
description: string | null
providerId: string | null
accountId: string | null
envKey: string | null
envOwnerUserId: string | null
createdBy: string
createdAt: string
updatedAt: string
role?: WorkspaceCredentialRole
status?: WorkspaceCredentialMemberStatus
}
export interface WorkspaceCredentialMember {
id: string
userId: string
role: WorkspaceCredentialRole
status: WorkspaceCredentialMemberStatus
joinedAt: string | null
invitedBy: string | null
createdAt: string
updatedAt: string
userName: string | null
userEmail: string | null
userImage: string | null
}
interface CredentialListResponse {
credentials?: WorkspaceCredential[]
}
interface CredentialResponse {
credential?: WorkspaceCredential | null
}
interface MembersResponse {
members?: WorkspaceCredentialMember[]
}
export const workspaceCredentialKeys = {
all: ['workspaceCredentials'] as const,
list: (workspaceId?: string, type?: string, providerId?: string) =>
['workspaceCredentials', workspaceId ?? 'none', type ?? 'all', providerId ?? 'all'] as const,
detail: (credentialId?: string) =>
['workspaceCredentials', 'detail', credentialId ?? 'none'] as const,
members: (credentialId?: string) =>
['workspaceCredentials', 'detail', credentialId ?? 'none', 'members'] as const,
}
export function useWorkspaceCredentials(params: {
workspaceId?: string
type?: WorkspaceCredentialType
providerId?: string
enabled?: boolean
}) {
const { workspaceId, type, providerId, enabled = true } = params
return useQuery<WorkspaceCredential[]>({
queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId),
queryFn: async () => {
if (!workspaceId) return []
const data = await fetchJson<CredentialListResponse>('/api/credentials', {
searchParams: {
workspaceId,
type,
providerId,
},
})
return data.credentials ?? []
},
enabled: Boolean(workspaceId) && enabled,
staleTime: 60 * 1000,
})
}
export function useWorkspaceCredential(credentialId?: string, enabled = true) {
return useQuery<WorkspaceCredential | null>({
queryKey: workspaceCredentialKeys.detail(credentialId),
queryFn: async () => {
if (!credentialId) return null
const data = await fetchJson<CredentialResponse>(`/api/credentials/${credentialId}`)
return data.credential ?? null
},
enabled: Boolean(credentialId) && enabled,
staleTime: 60 * 1000,
})
}
export function useCreateWorkspaceCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: {
workspaceId: string
type: WorkspaceCredentialType
displayName?: string
description?: string
providerId?: string
accountId?: string
envKey?: string
envOwnerUserId?: string
}) => {
const response = await fetch('/api/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create credential')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.list(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.all,
})
},
})
}
export function useUpdateWorkspaceCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: {
credentialId: string
displayName?: string
description?: string | null
accountId?: string
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: payload.displayName,
description: payload.description,
accountId: payload.accountId,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update credential')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.all,
})
},
})
}
export function useDeleteWorkspaceCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (credentialId: string) => {
const response = await fetch(`/api/credentials/${credentialId}`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to delete credential')
}
return response.json()
},
onSuccess: (_data, credentialId) => {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
},
})
}
export function useWorkspaceCredentialMembers(credentialId?: string) {
return useQuery<WorkspaceCredentialMember[]>({
queryKey: workspaceCredentialKeys.members(credentialId),
queryFn: async () => {
if (!credentialId) return []
const data = await fetchJson<MembersResponse>(`/api/credentials/${credentialId}/members`)
return data.members ?? []
},
enabled: Boolean(credentialId),
staleTime: 30 * 1000,
})
}
export function useUpsertWorkspaceCredentialMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: {
credentialId: string
userId: string
role: WorkspaceCredentialRole
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: payload.userId,
role: payload.role,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update credential member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
},
})
}
export function useRemoveWorkspaceCredentialMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { credentialId: string; userId: string }) => {
const response = await fetch(
`/api/credentials/${payload.credentialId}/members?userId=${encodeURIComponent(payload.userId)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to remove credential member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
},
})
}

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