improve collaborative UX

This commit is contained in:
Vikhyath Mondreti
2026-02-12 15:18:54 -08:00
parent 508772cf58
commit aefa281677
35 changed files with 528 additions and 217 deletions

View File

@@ -110,10 +110,13 @@ export async function POST(request: NextRequest) {
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, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })

View File

@@ -184,7 +184,10 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
}
}
await db.delete(credentialMember).where(eq(credentialMember.id, target.id))
await db
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -16,13 +16,20 @@ 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) => Boolean(data.displayName || data.accountId), {
message: 'At least one field must be provided',
path: ['displayName'],
})
.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
@@ -31,6 +38,7 @@ async function getCredentialResponse(credentialId: string, userId: string) {
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
@@ -99,23 +107,35 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'oauth') {
const updates: Record<string, unknown> = {}
if (parseResult.data.description !== undefined) {
updates.description = parseResult.data.description ?? null
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
return NextResponse.json(
{
error: 'OAuth credential editing is limited to description only.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'OAuth credential editing is disabled. Connect an account and create or use its linked credential.',
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ 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 })

View File

@@ -1,81 +0,0 @@
import { db } from '@sim/db'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialsBootstrapAPI')
const bootstrapSchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
})
/**
* Ensures the current user's connected accounts and env vars are reflected as workspace credentials.
*/
export async function POST(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parseResult = bootstrapSchema.safeParse(await request.json())
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId } = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [personalRow, workspaceRow] = await Promise.all([
db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, session.user.id))
.limit(1),
db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1),
])
const personalKeys = Object.keys((personalRow[0]?.variables as Record<string, string>) || {})
const workspaceKeys = Object.keys((workspaceRow[0]?.variables as Record<string, string>) || {})
const [oauthSyncResult] = await Promise.all([
syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }),
syncPersonalEnvCredentialsForUser({ userId: session.user.id, envKeys: personalKeys }),
syncWorkspaceEnvCredentials({
workspaceId,
envKeys: workspaceKeys,
actingUserId: session.user.id,
}),
])
return NextResponse.json({
success: true,
synced: {
oauthCreated: oauthSyncResult.createdCredentials,
oauthMembershipsUpdated: oauthSyncResult.updatedMemberships,
personalEnvKeys: personalKeys.length,
workspaceEnvKeys: workspaceKeys.length,
},
})
} catch (error) {
logger.error('Failed to bootstrap workspace credentials', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -14,6 +14,7 @@ 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(),
})
export async function POST(request: Request) {
@@ -29,7 +30,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { workspaceId, providerId, displayName } = parsed.data
const { workspaceId, providerId, displayName, description } = parsed.data
const userId = session.user.id
const now = new Date()
@@ -47,6 +48,7 @@ export async function POST(request: Request) {
workspaceId,
providerId,
displayName,
description: description || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
@@ -58,6 +60,7 @@ export async function POST(request: Request) {
],
set: {
displayName,
description: description || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},

View File

@@ -26,6 +26,7 @@ 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
@@ -33,6 +34,7 @@ const createCredentialSchema = z
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(),
@@ -156,10 +158,12 @@ export async function GET(request: NextRequest) {
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) {
@@ -172,13 +176,28 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, providerId } = parseResult.data
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) {
const [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)
return NextResponse.json({ credential: row ?? null })
}
if (!type || type === 'oauth') {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
@@ -202,6 +221,7 @@ export async function GET(request: NextRequest) {
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
@@ -245,8 +265,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, displayName, providerId, accountId, envKey, envOwnerUserId } =
parseResult.data
const {
workspaceId,
type,
displayName,
description,
providerId,
accountId,
envKey,
envOwnerUserId,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.canWrite) {
@@ -254,6 +282,7 @@ export async function POST(request: NextRequest) {
}
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
@@ -345,16 +374,21 @@ export async function POST(request: NextRequest) {
)
}
if (
const canUpdateExistingCredential = membership.role === 'admin'
const shouldUpdateDisplayName =
type === 'oauth' &&
membership.role === 'admin' &&
resolvedDisplayName &&
resolvedDisplayName !== existingCredential.displayName
) {
const shouldUpdateDescription =
typeof description !== 'undefined' &&
(existingCredential.description ?? null) !== resolvedDescription
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
await db
.update(credential)
.set({
displayName: resolvedDisplayName,
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
updatedAt: new Date(),
})
.where(eq(credential.id, existingCredential.id))
@@ -388,6 +422,7 @@ export async function POST(request: NextRequest) {
workspaceId,
type,
displayName: resolvedDisplayName,
description: resolvedDescription,
providerId: resolvedProviderId,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,

View File

@@ -11,6 +11,7 @@ import {
user,
userStats,
type WorkspaceInvitationStatus,
workspaceEnvironment,
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -23,6 +24,7 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
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
if (personalProToCancel) {
try {

View File

@@ -32,9 +32,10 @@
import crypto from 'crypto'
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 { and, count, eq } from 'drizzle-orm'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -232,6 +233,20 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
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({
id: permissionId,
workspaceId,

View File

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

View File

@@ -1,11 +1,12 @@
import crypto from 'crypto'
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 { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import {
getUsersWithPermissions,
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)
return NextResponse.json({

View File

@@ -6,6 +6,7 @@ import {
user,
type WorkspaceInvitationStatus,
workspace,
workspaceEnvironment,
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -14,6 +15,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -162,6 +164,20 @@ export async function GET(
.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()))
}

View File

@@ -116,26 +116,51 @@ export function CredentialSelector({
[credentialSets, selectedCredentialSetId]
)
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) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName
if (selectedId && !credentialsLoading) return 'Credential (no access)'
return ''
}, [selectedCredentialSet, selectedCredential])
}, [
selectedCredentialSet,
selectedCredential,
inaccessibleCredentialName,
selectedId,
credentialsLoading,
])
const displayValue = isEditing ? editingValue : resolvedLabel
const invalidSelection =
!isPreview && Boolean(selectedId) && !selectedCredential && !credentialsLoading
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const handleOpenChange = useCallback(

View File

@@ -85,19 +85,43 @@ export function ToolCredentialSelector({
[credentials, selectedId]
)
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
return ''
}, [selectedCredential])
const inputValue = isEditing ? editingInputValue : resolvedLabel
const invalidSelection = Boolean(selectedId) && !selectedCredential && !credentialsLoading
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!invalidSelection) return
onChange('')
}, [invalidSelection, onChange])
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) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName
if (selectedId && !credentialsLoading) return 'Credential (no access)'
return ''
}, [selectedCredential, inaccessibleCredentialName, selectedId, credentialsLoading])
const inputValue = isEditing ? editingInputValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)

View File

@@ -255,6 +255,54 @@ const WorkflowContent = React.memo(() => {
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,
} = JSON.parse(pending) as {
displayName: string
providerId: string
preCount: number
workspaceId: string
}
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 {
workflows,
activeWorkflowId,

View File

@@ -2,12 +2,13 @@
import { createElement, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Combobox,
Input,
Label,
@@ -16,6 +17,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
@@ -35,12 +37,12 @@ import {
useCreateWorkspaceCredential,
useDeleteWorkspaceCredential,
useRemoveWorkspaceCredentialMember,
useUpdateWorkspaceCredential,
useUpsertWorkspaceCredentialMember,
useWorkspaceCredentialMembers,
useWorkspaceCredentials,
type WorkspaceCredential,
type WorkspaceCredentialRole,
workspaceCredentialKeys,
} from '@/hooks/queries/credentials'
import {
usePersonalEnvironment,
@@ -62,12 +64,20 @@ const roleOptions = [
{ value: 'admin', label: 'Admin' },
] as const
const typeOptions = [
type CreateCredentialType = 'oauth' | 'secret'
type SecretScope = 'workspace' | 'personal'
const createTypeOptions = [
{ value: 'oauth', label: 'OAuth Account' },
{ value: 'env_workspace', label: 'Workspace Secret' },
{ value: 'env_personal', label: 'Personal Secret' },
{ value: 'secret', label: 'Secret' },
] as const
function getSecretCredentialType(
scope: SecretScope
): Extract<WorkspaceCredential['type'], 'env_workspace' | 'env_personal'> {
return scope === 'workspace' ? 'env_workspace' : 'env_personal'
}
function typeBadgeVariant(type: WorkspaceCredential['type']): 'blue' | 'amber' | 'gray-secondary' {
if (type === 'oauth') return 'blue'
if (type === 'env_workspace') return 'amber'
@@ -89,15 +99,16 @@ function normalizeEnvKeyInput(raw: string): string {
export function CredentialsManager() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const queryClient = useQueryClient()
const [searchTerm, setSearchTerm] = useState('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
const [memberRole, setMemberRole] = useState<WorkspaceCredentialRole>('member')
const [memberUserId, setMemberUserId] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [createType, setCreateType] = useState<WorkspaceCredential['type']>('oauth')
const [createType, setCreateType] = useState<CreateCredentialType>('oauth')
const [createSecretScope, setCreateSecretScope] = useState<SecretScope>('personal')
const [createDisplayName, setCreateDisplayName] = useState('')
const [createDescription, setCreateDescription] = useState('')
const [createEnvKey, setCreateEnvKey] = useState('')
const [createEnvValue, setCreateEnvValue] = useState('')
const [createOAuthProviderId, setCreateOAuthProviderId] = useState('')
@@ -105,6 +116,7 @@ export function CredentialsManager() {
const [detailsError, setDetailsError] = useState<string | null>(null)
const [selectedEnvValueDraft, setSelectedEnvValueDraft] = useState('')
const [isEditingEnvValue, setIsEditingEnvValue] = useState(false)
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -128,31 +140,6 @@ export function CredentialsManager() {
select: (data) => data,
})
const bootstrapCredentials = useMutation({
mutationFn: async () => {
if (!workspaceId) return null
const response = await fetch('/api/credentials/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to bootstrap credentials')
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.list(workspaceId) })
},
})
const runBootstrapCredentials = bootstrapCredentials.mutate
useEffect(() => {
if (!workspaceId) return
runBootstrapCredentials()
}, [workspaceId, runBootstrapCredentials])
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
const selectedCredential = useMemo(
() => credentials.find((credential) => credential.id === selectedCredentialId) || null,
@@ -164,6 +151,7 @@ export function CredentialsManager() {
)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
const deleteCredential = useDeleteWorkspaceCredential()
const upsertMember = useUpsertWorkspaceCredentialMember()
const removeMember = useRemoveWorkspaceCredentialMember()
@@ -182,6 +170,7 @@ export function CredentialsManager() {
return credentials.filter((credential) => {
return (
credential.displayName.toLowerCase().includes(normalized) ||
(credential.description || '').toLowerCase().includes(normalized) ||
(credential.providerId || '').toLowerCase().includes(normalized) ||
resolveProviderLabel(credential.providerId).toLowerCase().includes(normalized) ||
typeLabel(credential.type).toLowerCase().includes(normalized)
@@ -236,18 +225,21 @@ export function CredentialsManager() {
}
return getCanonicalScopesForProvider(createOAuthProviderId)
}, [selectedOAuthService, createOAuthProviderId])
const createSecretType = useMemo(
() => getSecretCredentialType(createSecretScope),
[createSecretScope]
)
const selectedExistingEnvCredential = useMemo(() => {
if (createType !== 'secret') return null
const envKey = normalizeEnvKeyInput(createEnvKey)
if (!envKey) return null
return (
credentials.find(
(row) =>
row.type === createType &&
row.type !== 'oauth' &&
(row.envKey || '').toLowerCase() === envKey.toLowerCase()
row.type === createSecretType && (row.envKey || '').toLowerCase() === envKey.toLowerCase()
) ?? null
)
}, [credentials, createEnvKey, createType])
}, [credentials, createEnvKey, createSecretType, createType])
const selectedEnvCurrentValue = useMemo(() => {
if (!selectedCredential || selectedCredential.type === 'oauth') return ''
const envKey = selectedCredential.envKey || ''
@@ -267,6 +259,26 @@ export function CredentialsManager() {
if (!selectedCredential || selectedCredential.type === 'oauth') return false
return selectedEnvValueDraft !== selectedEnvCurrentValue
}, [selectedCredential, selectedEnvValueDraft, selectedEnvCurrentValue])
useEffect(() => {
if (!selectedCredential || !isSelectedAdmin) return
if (selectedDescriptionDraft === (selectedCredential.description || '')) return
const timer = setTimeout(async () => {
try {
await updateCredential.mutateAsync({
credentialId: selectedCredential.id,
description: selectedDescriptionDraft.trim() || null,
})
await refetchCredentials()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to update description'
setDetailsError(message)
logger.error('Failed to autosave credential description', error)
}
}, 600)
return () => clearTimeout(timer)
}, [selectedDescriptionDraft])
useEffect(() => {
if (createType !== 'oauth') return
@@ -295,6 +307,7 @@ export function CredentialsManager() {
setShowCreateModal(true)
setShowCreateOAuthRequiredModal(false)
setCreateError(null)
setCreateDescription('')
setCreateEnvValue('')
if (request.type === 'oauth') {
@@ -303,7 +316,8 @@ export function CredentialsManager() {
setCreateDisplayName(request.displayName)
setCreateEnvKey('')
} else {
setCreateType(request.type)
setCreateType('secret')
setCreateSecretScope(request.type === 'env_workspace' ? 'workspace' : 'personal')
setCreateOAuthProviderId('')
setCreateDisplayName('')
setCreateEnvKey(request.envKey || '')
@@ -316,10 +330,13 @@ export function CredentialsManager() {
if (!selectedCredential) {
setSelectedEnvValueDraft('')
setIsEditingEnvValue(false)
setSelectedDescriptionDraft('')
return
}
setDetailsError(null)
setSelectedDescriptionDraft(selectedCredential.description || '')
if (selectedCredential.type === 'oauth') {
setSelectedEnvValueDraft('')
setIsEditingEnvValue(false)
@@ -351,7 +368,9 @@ export function CredentialsManager() {
const resetCreateForm = () => {
setCreateType('oauth')
setCreateSecretScope('personal')
setCreateDisplayName('')
setCreateDescription('')
setCreateEnvKey('')
setCreateEnvValue('')
setCreateOAuthProviderId('')
@@ -412,7 +431,6 @@ export function CredentialsManager() {
})
}
await bootstrapCredentials.mutateAsync()
await refetchCredentials()
setIsEditingEnvValue(false)
} catch (error: unknown) {
@@ -425,6 +443,7 @@ export function CredentialsManager() {
const handleCreateCredential = async () => {
if (!workspaceId) return
setCreateError(null)
const normalizedDescription = createDescription.trim()
try {
if (createType === 'oauth') {
@@ -451,7 +470,7 @@ export function CredentialsManager() {
return
}
if (createType === 'env_personal') {
if (createSecretType === 'env_personal') {
const personalVariables = Object.entries(personalEnvironment).reduce(
(acc, [key, value]) => ({
...acc,
@@ -476,21 +495,18 @@ export function CredentialsManager() {
},
})
}
if (selectedExistingEnvCredential) {
setSelectedCredentialId(selectedExistingEnvCredential.id)
} else {
const response = await createCredential.mutateAsync({
workspaceId,
type: createType,
envKey: normalizedEnvKey,
})
const credentialId = response?.credential?.id
if (credentialId) {
setSelectedCredentialId(credentialId)
}
const response = await createCredential.mutateAsync({
workspaceId,
type: createSecretType,
envKey: normalizedEnvKey,
description: normalizedDescription || undefined,
})
const credentialId = response?.credential?.id
if (credentialId) {
setSelectedCredentialId(credentialId)
}
await bootstrapCredentials.mutateAsync()
await refetchCredentials()
setShowCreateModal(false)
@@ -523,9 +539,20 @@ export function CredentialsManager() {
workspaceId,
providerId: selectedOAuthService.providerId,
displayName,
description: createDescription.trim() || undefined,
}),
})
window.sessionStorage.setItem(
'sim.oauth-connect-pending',
JSON.stringify({
displayName,
providerId: selectedOAuthService.providerId,
preCount: credentials.filter((c) => c.type === 'oauth').length,
workspaceId,
})
)
await connectOAuthService.mutateAsync({
providerId: selectedOAuthService.providerId,
callbackURL: window.location.href,
@@ -565,7 +592,6 @@ export function CredentialsManager() {
})
setSelectedCredentialId(null)
await bootstrapCredentials.mutateAsync()
await refetchCredentials()
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
@@ -646,9 +672,7 @@ export function CredentialsManager() {
</div>
) : sortedCredentials.length === 0 ? (
<div className='rounded-[8px] border border-[var(--border-1)] px-[12px] py-[10px] text-[12px] text-[var(--text-tertiary)]'>
{bootstrapCredentials.isPending
? 'Syncing credentials from connected accounts and secrets...'
: 'No credentials available for this workspace.'}
No credentials available for this workspace.
</div>
) : (
<div className='flex flex-col gap-[8px]'>
@@ -741,6 +765,19 @@ export function CredentialsManager() {
className='mt-[6px]'
/>
</div>
<div>
<Label htmlFor='credential-description'>Description</Label>
<Textarea
id='credential-description'
value={selectedDescriptionDraft}
onChange={(event) => setSelectedDescriptionDraft(event.target.value)}
placeholder='Add a description...'
maxLength={500}
autoComplete='off'
disabled={!isSelectedAdmin}
className='mt-[6px] min-h-[60px] resize-none'
/>
</div>
<div>
<Label>Connected service</Label>
<div className='mt-[6px] flex items-center gap-[10px] rounded-[8px] border border-[var(--border-1)] px-[10px] py-[8px]'>
@@ -818,6 +855,19 @@ export function CredentialsManager() {
Save
</Button>
)}
<div>
<Label htmlFor='credential-description'>Description</Label>
<Textarea
id='credential-description'
value={selectedDescriptionDraft}
onChange={(event) => setSelectedDescriptionDraft(event.target.value)}
placeholder='Add a description...'
maxLength={500}
autoComplete='off'
disabled={!isSelectedAdmin}
className='mt-[6px] min-h-[60px] resize-none'
/>
</div>
</div>
)}
{detailsError && (
@@ -950,14 +1000,16 @@ export function CredentialsManager() {
<Label>Type</Label>
<div className='mt-[6px]'>
<Combobox
options={typeOptions.map((option) => ({
options={createTypeOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
value={typeOptions.find((option) => option.value === createType)?.label || ''}
value={
createTypeOptions.find((option) => option.value === createType)?.label || ''
}
selectedValue={createType}
onChange={(value) => {
setCreateType(value as WorkspaceCredential['type'])
setCreateType(value as CreateCredentialType)
setCreateError(null)
}}
placeholder='Select credential type'
@@ -977,6 +1029,17 @@ export function CredentialsManager() {
className='mt-[6px]'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
className='mt-[6px] min-h-[80px] resize-none'
/>
</div>
<div>
<Label>OAuth service</Label>
<div className='mt-[6px]'>
@@ -993,16 +1056,31 @@ export function CredentialsManager() {
/>
</div>
</div>
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Connecting creates a credential for this workspace. Disconnecting from that
credential removes it.
</p>
</div>
</div>
) : (
<div className='flex flex-col gap-[10px]'>
<div>
<Label className='block'>Scope</Label>
<div className='mt-[6px]'>
<ButtonGroup
value={createSecretScope}
onValueChange={(value) => setCreateSecretScope(value as SecretScope)}
>
<ButtonGroupItem
value='personal'
className='h-[28px] min-w-[72px] px-[10px] py-0 text-[12px]'
>
Personal
</ButtonGroupItem>
<ButtonGroupItem
value='workspace'
className='h-[28px] min-w-[80px] px-[10px] py-0 text-[12px]'
>
Workspace
</ButtonGroupItem>
</ButtonGroup>
</div>
</div>
<div>
<Label>Secret key</Label>
<Input
@@ -1039,6 +1117,17 @@ export function CredentialsManager() {
className='mt-[6px]'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
className='mt-[6px] min-h-[80px] resize-none'
/>
</div>
{selectedExistingEnvCredential && (
<div className='rounded-[8px] border border-[var(--brand-9)]/40 bg-[var(--surface-3)] px-[10px] py-[8px]'>
@@ -1091,7 +1180,6 @@ export function CredentialsManager() {
createCredential.isPending ||
savePersonalEnvironment.isPending ||
upsertWorkspaceEnvironment.isPending ||
bootstrapCredentials.isPending ||
disconnectOAuthService.isPending
}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export interface WorkspaceCredential {
workspaceId: string
type: WorkspaceCredentialType
displayName: string
description: string | null
providerId: string | null
accountId: string | null
envKey: string | null
@@ -107,6 +108,7 @@ export function useCreateWorkspaceCredential() {
workspaceId: string
type: WorkspaceCredentialType
displayName?: string
description?: string
providerId?: string
accountId?: string
envKey?: string
@@ -143,6 +145,7 @@ export function useUpdateWorkspaceCredential() {
mutationFn: async (payload: {
credentialId: string
displayName?: string
description?: string | null
accountId?: string
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
@@ -150,6 +153,7 @@ export function useUpdateWorkspaceCredential() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: payload.displayName,
description: payload.description,
accountId: payload.accountId,
}),
})

View File

@@ -270,6 +270,7 @@ export const auth = betterAuth({
workspaceId: draft.workspaceId,
type: 'oauth',
displayName: draft.displayName,
description: draft.description ?? null,
providerId: account.providerId,
accountId: account.id,
createdBy: account.userId,

View File

@@ -23,9 +23,14 @@ export interface CredentialAccessResult {
*/
export async function authorizeCredentialUse(
request: NextRequest,
params: { credentialId: string; workflowId?: string; requireWorkflowIdForInternal?: boolean }
params: {
credentialId: string
workflowId?: string
requireWorkflowIdForInternal?: boolean
callerUserId?: string
}
): Promise<CredentialAccessResult> {
const { credentialId, workflowId, requireWorkflowIdForInternal = true } = params
const { credentialId, workflowId, requireWorkflowIdForInternal = true, callerUserId } = params
const auth = await checkSessionOrInternalAuth(request, {
requireWorkflowId: requireWorkflowIdForInternal,
@@ -76,26 +81,39 @@ export async function authorizeCredentialUse(
return { ok: false, error: 'Credential account not found' }
}
const requesterPerm =
auth.authType === 'internal_jwt'
? null
: await getUserEntityPermissions(auth.userId, 'workspace', platformCredential.workspaceId)
const effectiveCallerId =
callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
if (effectiveCallerId) {
const requesterPerm = await getUserEntityPermissions(
effectiveCallerId,
'workspace',
platformCredential.workspaceId
)
if (auth.authType !== 'internal_jwt') {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, auth.userId),
eq(credentialMember.userId, effectiveCallerId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership || requesterPerm === null) {
return { ok: false, error: 'Unauthorized' }
if (!membership) {
return {
ok: false,
error: `You do not have access to this credential. Ask the credential admin to add you as a member.`,
}
}
if (requesterPerm === null) {
return {
ok: false,
error: 'You do not have access to this workspace.',
}
}
}
@@ -149,21 +167,27 @@ export async function authorizeCredentialUse(
return { ok: false, error: 'Credential account not found' }
}
if (auth.authType !== 'internal_jwt') {
const legacyCallerId = callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
if (legacyCallerId) {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, workspaceCredential.id),
eq(credentialMember.userId, auth.userId),
eq(credentialMember.userId, legacyCallerId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return { ok: false, error: 'Unauthorized' }
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
}

View File

@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
interface SetEnvironmentVariablesParams {
variables: Record<string, any> | Array<{ name: string; value: string }>
@@ -108,6 +109,11 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
set: { variables: finalEncrypted, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: authenticatedUserId,
envKeys: Object.keys(finalEncrypted),
})
logger.info('Saved personal environment variables', {
userId: authenticatedUserId,
addedCount: added.length,

View File

@@ -106,6 +106,7 @@ async function ensureWorkspaceCredentialMemberships(
.select({
id: credentialMember.id,
userId: credentialMember.userId,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
})
.from(credentialMember)
@@ -123,6 +124,9 @@ async function ensureWorkspaceCredentialMemberships(
const targetRole = memberUserId === ownerUserId ? 'admin' : 'member'
const existing = byUserId.get(memberUserId)
if (existing) {
if (existing.status === 'revoked') {
continue
}
await db
.update(credentialMember)
.set({

View File

@@ -1,8 +1,11 @@
import { db } from '@sim/db'
import { account, credential, credentialMember } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq, inArray, notInArray } from 'drizzle-orm'
import { getServiceConfigByProviderId } from '@/lib/oauth'
/** Provider IDs that are not real OAuth integrations (e.g. Better Auth's password provider) */
const NON_OAUTH_PROVIDER_IDS = ['credential'] as const
interface SyncWorkspaceOAuthCredentialsForUserParams {
workspaceId: string
userId: string
@@ -34,7 +37,9 @@ export async function syncWorkspaceOAuthCredentialsForUser(
accountId: account.accountId,
})
.from(account)
.where(eq(account.userId, userId))
.where(
and(eq(account.userId, userId), notInArray(account.providerId, [...NON_OAUTH_PROVIDER_IDS]))
)
if (userAccounts.length === 0) {
return { createdCredentials: 0, updatedMemberships: 0 }

View File

@@ -302,6 +302,7 @@ export async function executeWorkflowCore(
workspaceId: providedWorkspaceId,
userId,
isDeployedContext: !metadata.isClientSession,
enforceCredentialAccess: metadata.enforceCredentialAccess ?? false,
onBlockStart,
onBlockComplete: wrappedOnBlockComplete,
onStream,

View File

@@ -303,7 +303,7 @@ export async function executeTool(
if (workflowId) {
tokenUrlObj.searchParams.set('workflowId', workflowId)
}
if (userId) {
if (userId && contextParams._context?.enforceCredentialAccess) {
tokenUrlObj.searchParams.set('userId', userId)
}
@@ -330,7 +330,15 @@ export async function executeTool(
status: response.status,
error: errorText,
})
throw new Error(`Failed to fetch access token: ${response.status} ${errorText}`)
let parsedError = errorText
try {
const parsed = JSON.parse(errorText)
if (parsed.error) parsedError = parsed.error
} catch {
// Use raw text
}
const toolLabel = tool?.name || toolId
throw new Error(`Failed to obtain credential for ${toolLabel}: ${parsedError}`)
}
const data = await response.json()
@@ -359,10 +367,7 @@ export async function executeTool(
logger.error(`[${requestId}] Error fetching access token for ${toolId}:`, {
error: error instanceof Error ? error.message : String(error),
})
// Re-throw the error to fail the tool execution if token fetching fails
throw new Error(
`Failed to obtain credential for tool ${toolId}: ${error instanceof Error ? error.message : String(error)}`
)
throw error
}
}