From aefa2816779439eb913ec89bd4bc8b9ce573da64 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 12 Feb 2026 15:18:54 -0800 Subject: [PATCH] improve collaborative UX --- apps/sim/app/api/auth/oauth/token/route.ts | 3 + .../app/api/credentials/[id]/members/route.ts | 5 +- apps/sim/app/api/credentials/[id]/route.ts | 46 +++- .../app/api/credentials/bootstrap/route.ts | 81 ------- apps/sim/app/api/credentials/draft/route.ts | 5 +- apps/sim/app/api/credentials/route.ts | 49 +++- .../[id]/invitations/[invitationId]/route.ts | 30 +++ .../v1/admin/workspaces/[id]/members/route.ts | 17 +- .../app/api/workflows/[id]/execute/route.ts | 2 + .../api/workspaces/[id]/permissions/route.ts | 17 +- .../invitations/[invitationId]/route.ts | 16 ++ .../credential-selector.tsx | 51 +++- .../components/tool-credential-selector.tsx | 46 +++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 48 ++++ .../credentials/credentials-manager.tsx | 222 ++++++++++++------ apps/sim/executor/execution/executor.ts | 1 + apps/sim/executor/execution/types.ts | 2 + .../executor/handlers/agent/agent-handler.ts | 1 + apps/sim/executor/handlers/api/api-handler.ts | 1 + .../handlers/condition/condition-handler.ts | 1 + .../handlers/function/function-handler.ts | 1 + .../handlers/generic/generic-handler.ts | 1 + .../human-in-the-loop-handler.ts | 1 + .../handlers/workflow/workflow-handler.ts | 1 + apps/sim/executor/types.ts | 1 + apps/sim/hooks/queries/credentials.ts | 4 + apps/sim/lib/auth/auth.ts | 1 + apps/sim/lib/auth/credential-access.ts | 50 +++- .../server/user/set-environment-variables.ts | 6 + apps/sim/lib/credentials/environment.ts | 4 + apps/sim/lib/credentials/oauth.ts | 9 +- .../lib/workflows/executor/execution-core.ts | 1 + apps/sim/tools/index.ts | 17 +- .../migrations/0154_luxuriant_maria_hill.sql | 2 + packages/db/schema.ts | 2 + 35 files changed, 528 insertions(+), 217 deletions(-) delete mode 100644 apps/sim/app/api/credentials/bootstrap/route.ts diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 0282495ae..d8b1b4574 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -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 }) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 4289cc121..d312d5170 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -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) { diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index f393aea9a..fe1f10f66 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -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 = {} + + 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 }) diff --git a/apps/sim/app/api/credentials/bootstrap/route.ts b/apps/sim/app/api/credentials/bootstrap/route.ts deleted file mode 100644 index b36026f5c..000000000 --- a/apps/sim/app/api/credentials/bootstrap/route.ts +++ /dev/null @@ -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) || {}) - const workspaceKeys = Object.keys((workspaceRow[0]?.variables as Record) || {}) - - 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 }) - } -} diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index e55248b15..ca58a9a44 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -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, }, diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 87d71d3b0..ca3cf084e 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -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, diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index d28e545fd..bf9851058 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -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) || {}) + 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 { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index 687198506..78298feb4 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -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(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) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: wsEnvKeys, + actingUserId: body.userId, + }) + } + return singleResponse({ id: permissionId, workspaceId, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 13fc0ff41..7453cb41d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -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, } diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 0025c90fc..a9fbc0f06 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -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) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: wsEnvKeys, + actingUserId: session.user.id, + }) + } + const updatedUsers = await getUsersWithPermissions(workspaceId) return NextResponse.json({ diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index c7574a61e..1fbc1bbda 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -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) || {}) + 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())) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index e8f5684e1..13cf2c776 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -116,26 +116,51 @@ export function CredentialSelector({ [credentialSets, selectedCredentialSetId] ) + const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState(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( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index 488e7f12b..910bb6a48 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -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(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) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 66fa0ee16..b4fda44e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx index 4cf3dd16f..2da29fa52 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx @@ -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 { + 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(null) const [memberRole, setMemberRole] = useState('member') const [memberUserId, setMemberUserId] = useState('') const [showCreateModal, setShowCreateModal] = useState(false) - const [createType, setCreateType] = useState('oauth') + const [createType, setCreateType] = useState('oauth') + const [createSecretScope, setCreateSecretScope] = useState('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(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() { ) : sortedCredentials.length === 0 ? (
- {bootstrapCredentials.isPending - ? 'Syncing credentials from connected accounts and secrets...' - : 'No credentials available for this workspace.'} + No credentials available for this workspace.
) : (
@@ -741,6 +765,19 @@ export function CredentialsManager() { className='mt-[6px]' />
+
+ +