diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index a51d8585c2..aebb5d6a28 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' 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 { getSession } from '@/lib/auth' @@ -31,15 +31,13 @@ export async function GET(request: NextRequest) { }) .from(account) .where(and(...whereConditions)) - - // Use the user's email as the display name (consistent with credential selector) - const userEmail = session.user.email + .orderBy(desc(account.updatedAt)) const accountsWithDisplayName = accounts.map((acc) => ({ id: acc.id, accountId: acc.accountId, providerId: acc.providerId, - displayName: userEmail || acc.providerId, + displayName: acc.accountId || acc.providerId, })) return NextResponse.json({ accounts: accountsWithDisplayName }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 7809e5543c..8c4b42dc1e 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -1,5 +1,5 @@ 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 { and, eq } from 'drizzle-orm' import { jwtDecode } from 'jwt-decode' @@ -7,8 +7,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -18,6 +20,7 @@ const credentialsQuerySchema = z .object({ provider: z.string().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 .string() .min(1, 'Credential ID must not be empty') @@ -35,6 +38,79 @@ interface GoogleIdToken { 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(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 */ @@ -46,6 +122,7 @@ export async function GET(request: NextRequest) { const rawQuery = { provider: searchParams.get('provider'), workflowId: searchParams.get('workflowId'), + workspaceId: searchParams.get('workspaceId'), 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) const authResult = await checkSessionOrInternalAuth(request) @@ -88,7 +165,7 @@ export async function GET(request: NextRequest) { } const requesterUserId = authResult.userId - const effectiveUserId = requesterUserId + let effectiveWorkspaceId = workspaceId ?? undefined if (workflowId) { const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ workflowId, @@ -106,101 +183,145 @@ export async function GET(request: NextRequest) { { status: workflowAuthorization.status } ) } + effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined } - // Parse the provider to get base provider and feature type (if provider is present) - const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider) + if (effectiveWorkspaceId) { + const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId) + if (!workspaceAccess.hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + } 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) { - // 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)) } else if (credentialId) { accountsData = await db .select() .from(account) - .where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId))) + .where(and(eq(account.userId, requesterUserId), eq(account.id, credentialId))) } else { - // Fetch all credentials for provider and effective user accountsData = await db .select() .from(account) - .where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!))) + .where(and(eq(account.userId, requesterUserId), eq(account.providerId, providerParam!))) } // Transform accounts into credentials const credentials = await Promise.all( accountsData.map(async (acc) => { - // Extract the feature type from providerId (e.g., 'google-default' -> 'default') - const [_, featureType = 'default'] = acc.providerId.split('-') - - // 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(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, - } + const displayName = await getFallbackDisplayName(requestId, providerParam, acc) + return toCredentialResponse(acc.id, displayName, acc.providerId, acc.updatedAt, acc.scope) }) ) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index be645aa732..2ac3ff2fc8 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -15,6 +15,7 @@ const logger = createLogger('OAuthDisconnectAPI') const disconnectSchema = z.object({ provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'), 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`, { provider, hasProviderId: !!providerId, }) - // If a specific providerId is provided, delete only that account - if (providerId) { + // If a specific account row ID is provided, delete that exact account + 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 .delete(account) .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts index af9d5d47e8..c653d35bf6 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -38,13 +38,18 @@ export async function GET(request: NextRequest) { 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) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } const accessToken = await refreshAccessTokenIfNeeded( - credentialId, + resolvedCredentialId, authz.credentialOwnerUserId, requestId ) diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index 1a689b808d..23bd2e57e5 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -37,14 +37,19 @@ export async function GET(request: NextRequest) { 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) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } // Refresh access token if needed using the utility function const accessToken = await refreshAccessTokenIfNeeded( - credentialId, + resolvedCredentialId, authz.credentialOwnerUserId, requestId ) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 325f4d6c2c..7363578af7 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -351,10 +351,11 @@ describe('OAuth Token API Routes', () => { */ describe('GET handler', () => { it('should return access token successfully', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, authType: 'session', - userId: 'test-user-id', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'test-user-id', }) mockGetCredential.mockResolvedValueOnce({ id: 'credential-id', @@ -380,8 +381,8 @@ describe('OAuth Token API Routes', () => { expect(response.status).toBe(200) expect(data).toHaveProperty('accessToken', 'fresh-token') - expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled() - expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id') + expect(mockAuthorizeCredentialUse).toHaveBeenCalled() + expect(mockGetCredential).toHaveBeenCalled() expect(mockRefreshTokenIfNeeded).toHaveBeenCalled() }) @@ -399,8 +400,8 @@ describe('OAuth Token API Routes', () => { }) it('should handle authentication failure', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: false, + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: false, error: 'Authentication required', }) @@ -413,15 +414,16 @@ describe('OAuth Token API Routes', () => { const response = await GET(req as any) const data = await response.json() - expect(response.status).toBe(401) + expect(response.status).toBe(403) expect(data).toHaveProperty('error') }) it('should handle credential not found', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, authType: 'session', - userId: 'test-user-id', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'test-user-id', }) mockGetCredential.mockResolvedValueOnce(undefined) @@ -439,10 +441,11 @@ describe('OAuth Token API Routes', () => { }) it('should handle missing access token', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, authType: 'session', - userId: 'test-user-id', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'test-user-id', }) mockGetCredential.mockResolvedValueOnce({ id: 'credential-id', @@ -465,10 +468,11 @@ describe('OAuth Token API Routes', () => { }) it('should handle token refresh failure', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, authType: 'session', - userId: 'test-user-id', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'test-user-id', }) mockGetCredential.mockResolvedValueOnce({ id: 'credential-id', diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index f6728fe696..d8b1b45741 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -110,23 +110,35 @@ 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 }) } - const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) + const resolvedCredentialId = authz.resolvedCredentialId || credentialId + const credential = await getCredential( + requestId, + resolvedCredentialId, + authz.credentialOwnerUserId + ) if (!credential) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } try { - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + const { accessToken } = await refreshTokenIfNeeded( + requestId, + credential, + resolvedCredentialId + ) let instanceUrl: string | undefined if (credential.providerId === 'salesforce' && credential.scope) { @@ -186,13 +198,20 @@ export async function GET(request: NextRequest) { const { credentialId } = parseResult.data - // For GET requests, we only support session-based authentication - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || auth.authType !== 'session' || !auth.userId) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + 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) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) @@ -204,7 +223,11 @@ export async function GET(request: NextRequest) { } try { - const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + const { accessToken } = await refreshTokenIfNeeded( + requestId, + credential, + resolvedCredentialId + ) // For Salesforce, extract instanceUrl from the scope field let instanceUrl: string | undefined diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 352ba5e786..7320a7bb9a 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -62,21 +62,23 @@ describe('OAuth Utils', () => { describe('getCredential', () => { it('should return credential when found', async () => { - const mockCredential = { id: 'credential-id', userId: 'test-user-id' } - const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential]) + const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' } + 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') - expect(mockDb.select).toHaveBeenCalled() - expect(mockFrom).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() - expect(mockLimit).toHaveBeenCalledWith(1) + expect(mockDb.select).toHaveBeenCalledTimes(2) - 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 () => { mockSelectChain([]) + mockSelectChain([]) const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') @@ -158,15 +160,17 @@ describe('OAuth Utils', () => { describe('refreshAccessTokenIfNeeded', () => { it('should return valid access token without refresh if not expired', async () => { - const mockCredential = { - id: 'credential-id', + const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } + const mockAccountRow = { + id: 'account-id', accessToken: 'valid-token', refreshToken: 'refresh-token', accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), providerId: 'google', userId: 'test-user-id', } - mockSelectChain([mockCredential]) + mockSelectChain([mockCredentialRow]) + mockSelectChain([mockAccountRow]) 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 () => { - const mockCredential = { - id: 'credential-id', + const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } + const mockAccountRow = { + id: 'account-id', accessToken: 'expired-token', refreshToken: 'refresh-token', accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), providerId: 'google', userId: 'test-user-id', } - mockSelectChain([mockCredential]) + mockSelectChain([mockCredentialRow]) + mockSelectChain([mockAccountRow]) mockUpdateChain() mockRefreshOAuthToken.mockResolvedValueOnce({ @@ -201,6 +207,7 @@ describe('OAuth Utils', () => { it('should return null if credential not found', async () => { mockSelectChain([]) + mockSelectChain([]) 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 () => { - const mockCredential = { - id: 'credential-id', + const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } + const mockAccountRow = { + id: 'account-id', accessToken: 'expired-token', refreshToken: 'refresh-token', accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), providerId: 'google', userId: 'test-user-id', } - mockSelectChain([mockCredential]) + mockSelectChain([mockCredentialRow]) + mockSelectChain([mockAccountRow]) mockRefreshOAuthToken.mockResolvedValueOnce(null) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 891e4ca4d1..c6a4626813 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,5 +1,5 @@ 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 { and, desc, eq, inArray } from 'drizzle-orm' import { refreshOAuthToken } from '@/lib/oauth' @@ -25,6 +25,28 @@ interface AccountInsertData { 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. * 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 */ 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 .select() .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) if (!credentials.length) { @@ -63,7 +91,10 @@ export async function getCredential(requestId: string, credentialId: string, use return undefined } - return credentials[0] + return { + ...credentials[0], + resolvedCredentialId: resolved.accountId, + } } export async function getOAuthToken(userId: string, providerId: string): Promise { @@ -238,7 +269,9 @@ export async function refreshAccessTokenIfNeeded( } // 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`) return refreshedToken.accessToken @@ -274,6 +307,8 @@ export async function refreshTokenIfNeeded( credential: any, credentialId: string ): Promise<{ accessToken: string; refreshed: boolean }> { + const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId + // Decide if we should refresh: token missing OR expired const accessTokenExpiresAt = credential.accessTokenExpiresAt const refreshTokenExpiresAt = credential.refreshTokenExpiresAt @@ -334,7 +369,7 @@ export async function refreshTokenIfNeeded( 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`) return { accessToken: refreshedToken, refreshed: true } @@ -343,7 +378,7 @@ export async function refreshTokenIfNeeded( `[${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) { const freshExpiresAt = freshCredential.accessTokenExpiresAt const stillValid = !freshExpiresAt || freshExpiresAt > new Date() diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index cf7aef92a6..85729149b9 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -48,16 +48,21 @@ export async function GET(request: NextRequest) { const shopData = await shopResponse.json() const shopInfo = shopData.shop + const stableAccountId = shopInfo.id?.toString() || shopDomain 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 accountData = { accessToken: accessToken, - accountId: shopInfo.id?.toString() || shopDomain, + accountId: stableAccountId, scope: scope || '', updatedAt: now, idToken: shopDomain, diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index fff52b0a84..97fc9b8abc 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -52,7 +52,11 @@ export async function POST(request: NextRequest) { const trelloUser = await userResponse.json() 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() diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts new file mode 100644 index 0000000000..11cee7f717 --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -0,0 +1,226 @@ +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CredentialMembersAPI') + +interface RouteContext { + params: Promise<{ id: string }> +} + +async function requireWorkspaceAdminMembership(credentialId: string, userId: string) { + const [cred] = await db + .select({ id: credential.id, workspaceId: credential.workspaceId }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if (!cred) return null + + const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) + if (perm === null) return null + + const [membership] = await db + .select({ role: credentialMember.role, status: credentialMember.status }) + .from(credentialMember) + .where( + and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) + ) + .limit(1) + + if (!membership || membership.status !== 'active' || membership.role !== 'admin') { + return null + } + return membership +} + +export async function GET(_request: NextRequest, context: RouteContext) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: credentialId } = await context.params + + const [cred] = await db + .select({ id: credential.id, workspaceId: credential.workspaceId }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if (!cred) { + return NextResponse.json({ members: [] }, { status: 200 }) + } + + const callerPerm = await getUserEntityPermissions( + session.user.id, + 'workspace', + cred.workspaceId + ) + if (callerPerm === null) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const members = await db + .select({ + id: credentialMember.id, + userId: credentialMember.userId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + }) + .from(credentialMember) + .innerJoin(user, eq(credentialMember.userId, user.id)) + .where(eq(credentialMember.credentialId, credentialId)) + + return NextResponse.json({ members }) + } catch (error) { + logger.error('Failed to fetch credential members', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +const addMemberSchema = z.object({ + userId: z.string().min(1), + role: z.enum(['admin', 'member']).default('member'), +}) + +export async function POST(request: NextRequest, context: RouteContext) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: credentialId } = await context.params + + const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id) + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } + + const body = await request.json() + const parsed = addMemberSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const { userId, role } = parsed.data + const now = new Date() + + const [existing] = await db + .select({ id: credentialMember.id, status: credentialMember.status }) + .from(credentialMember) + .where( + and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) + ) + .limit(1) + + if (existing) { + await db + .update(credentialMember) + .set({ role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return NextResponse.json({ success: true }) + } + + await db.insert(credentialMember).values({ + id: crypto.randomUUID(), + credentialId, + userId, + role, + status: 'active', + joinedAt: now, + invitedBy: session.user.id, + createdAt: now, + updatedAt: now, + }) + + return NextResponse.json({ success: true }, { status: 201 }) + } catch (error) { + logger.error('Failed to add credential member', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: credentialId } = await context.params + const targetUserId = new URL(request.url).searchParams.get('userId') + if (!targetUserId) { + return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) + } + + const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id) + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } + + const [target] = await db + .select({ + id: credentialMember.id, + role: credentialMember.role, + }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, credentialId), + eq(credentialMember.userId, targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + + if (!target) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + const revoked = await db.transaction(async (tx) => { + if (target.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + + if (activeAdmins.length <= 1) { + return false + } + } + + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, target.id)) + + return true + }) + + if (!revoked) { + return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to remove credential member', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts new file mode 100644 index 0000000000..7da93846c7 --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -0,0 +1,251 @@ +import { db } from '@sim/db' +import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + syncPersonalEnvCredentialsForUser, + syncWorkspaceEnvCredentials, +} from '@/lib/credentials/environment' + +const logger = createLogger('CredentialByIdAPI') + +const updateCredentialSchema = z + .object({ + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).nullish(), + }) + .strict() + .refine((data) => data.displayName !== undefined || data.description !== undefined, { + message: 'At least one field must be provided', + path: ['displayName'], + }) + +async function getCredentialResponse(credentialId: string, userId: string) { + const [row] = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + description: credential.description, + providerId: credential.providerId, + accountId: credential.accountId, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + createdBy: credential.createdBy, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + role: credentialMember.role, + status: credentialMember.status, + }) + .from(credential) + .innerJoin( + credentialMember, + and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId)) + ) + .where(eq(credential.id, credentialId)) + .limit(1) + + return row ?? null +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + try { + const access = await getCredentialActorContext(id, session.user.id) + if (!access.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + if (!access.hasWorkspaceAccess || !access.member) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const row = await getCredentialResponse(id, session.user.id) + return NextResponse.json({ credential: row }, { status: 200 }) + } catch (error) { + logger.error('Failed to fetch credential', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + try { + const parseResult = updateCredentialSchema.safeParse(await request.json()) + if (!parseResult.success) { + return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + } + + const access = await getCredentialActorContext(id, session.user.id) + if (!access.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) + } + + const updates: Record = {} + + 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 | 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 | 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 }) + } +} diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts new file mode 100644 index 0000000000..ac700f088e --- /dev/null +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -0,0 +1,116 @@ +import { db } from '@sim/db' +import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, lt } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CredentialDraftAPI') + +const DRAFT_TTL_MS = 15 * 60 * 1000 + +const createDraftSchema = z.object({ + workspaceId: z.string().min(1), + providerId: z.string().min(1), + displayName: z.string().min(1), + description: z.string().trim().max(500).optional(), + credentialId: z.string().min(1).optional(), +}) + +export async function POST(request: Request) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const parsed = createDraftSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const { workspaceId, providerId, displayName, description, credentialId } = parsed.data + const userId = session.user.id + + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.canWrite) { + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } + + if (credentialId) { + const [membership] = await db + .select({ role: credentialMember.role, status: credentialMember.status }) + .from(credentialMember) + .innerJoin(credential, eq(credential.id, credentialMember.credentialId)) + .where( + and( + eq(credentialMember.credentialId, credentialId), + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active'), + eq(credentialMember.role, 'admin'), + eq(credential.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (!membership) { + return NextResponse.json( + { error: 'Admin access required on the target credential' }, + { status: 403 } + ) + } + } + + const now = new Date() + + await db + .delete(pendingCredentialDraft) + .where( + and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) + ) + + await db + .insert(pendingCredentialDraft) + .values({ + id: crypto.randomUUID(), + userId, + workspaceId, + providerId, + displayName, + description: description || null, + credentialId: credentialId || null, + expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), + createdAt: now, + }) + .onConflictDoUpdate({ + target: [ + pendingCredentialDraft.userId, + pendingCredentialDraft.providerId, + pendingCredentialDraft.workspaceId, + ], + set: { + displayName, + description: description || null, + credentialId: credentialId || null, + expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), + createdAt: now, + }, + }) + + logger.info('Credential draft saved', { + userId, + workspaceId, + providerId, + displayName, + credentialId: credentialId || null, + }) + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + logger.error('Failed to save credential draft', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts new file mode 100644 index 0000000000..4fdf8379fa --- /dev/null +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts new file mode 100644 index 0000000000..c0d71d173b --- /dev/null +++ b/apps/sim/app/api/credentials/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index ad2818b0d1..c8e8604d23 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' +import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment' import type { EnvironmentVariable } from '@/stores/settings/environment' 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 }) } catch (validationError) { if (validationError instanceof z.ZodError) { 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 d28e545fdf..bf98510580 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/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index 49092d86df..30afdda571 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -25,6 +25,7 @@ import { db } from '@sim/db' import { permissions, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -215,6 +216,8 @@ export const DELETE = withAdminAuthParams(async (_, context) => { await db.delete(permissions).where(eq(permissions.id, memberId)) + await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId) + logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { userId: existingMember.userId, }) 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 687198506c..78298feb49 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 b6ed6bd8b3..b0ceb9a2c6 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, } @@ -885,6 +886,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]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index f11da0ecc9..a66849448d 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -1,12 +1,14 @@ import { db } from '@sim/db' -import { environment, workspaceEnvironment } from '@sim/db/schema' +import { 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 { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { encryptSecret } from '@/lib/core/security/encryption' 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' const logger = createLogger('WorkspaceEnvironmentAPI') @@ -44,44 +46,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Workspace env (encrypted) - const wsEnvRow = await db - .select() - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - - const wsEncrypted: Record = (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 = (personalRow[0]?.variables as any) || {} - - // Decrypt both for UI - const decryptAll = async (src: Record) => { - const out: Record = {} - 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) + const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( + userId, + workspaceId + ) return NextResponse.json( { @@ -156,6 +124,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ set: { variables: merged, updatedAt: new Date() }, }) + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: Object.keys(merged), + actingUserId: userId, + }) + return NextResponse.json({ success: true }) } catch (error: any) { logger.error(`[${requestId}] Workspace env PUT error`, error) @@ -222,6 +196,12 @@ export async function DELETE( set: { variables: current, updatedAt: new Date() }, }) + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: Object.keys(current), + actingUserId: userId, + }) + return NextResponse.json({ success: true }) } catch (error: any) { logger.error(`[${requestId}] Workspace env DELETE error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 0025c90fc0..a9fbc0f065 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.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts index 389de676cd..c1238a6b0c 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -8,15 +8,27 @@ const mockHasWorkspaceAdminAccess = vi.fn() let dbSelectResults: any[] = [] let dbSelectCallIndex = 0 -const mockDbSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn().mockImplementation((callback: (rows: any[]) => any) => { - const result = dbSelectResults[dbSelectCallIndex] || [] - dbSelectCallIndex++ - return Promise.resolve(callback ? callback(result) : result) - }), -})) +const mockDbSelect = vi.fn().mockImplementation(() => { + const makeThen = () => + vi.fn().mockImplementation((callback: (rows: any[]) => any) => { + const result = dbSelectResults[dbSelectCallIndex] || [] + dbSelectCallIndex++ + 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(() => ({ values: vi.fn().mockResolvedValue(undefined), @@ -53,6 +65,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ mockHasWorkspaceAdminAccess(userId, workspaceId), })) +vi.mock('@/lib/credentials/environment', () => ({ + syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/core/utils/urls', () => ({ @@ -95,6 +111,10 @@ vi.mock('@sim/db/schema', () => ({ userId: 'userId', permissionType: 'permissionType', }, + workspaceEnvironment: { + workspaceId: 'workspaceId', + variables: 'variables', + }, })) vi.mock('drizzle-orm', () => ({ @@ -207,6 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => { [mockWorkspace], [{ ...mockUser, email: 'invited@example.com' }], [], + [], ] const request = new NextRequest( @@ -460,6 +481,7 @@ describe('Workspace Invitation [invitationId] API Route', () => { [mockWorkspace], [{ ...mockUser, email: 'invited@example.com' }], [], + [], ] const request2 = new NextRequest( diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index c7574a61e2..1fbc1bbda4 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/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index ec990da241..9d9364807b 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceMemberAPI') @@ -101,6 +102,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i ) ) + await revokeWorkspaceCredentialMemberships(workspaceId, userId) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error removing workspace member:', error) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 4888a9684c..671a7c3f62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -30,6 +30,7 @@ export interface OAuthRequiredModalProps { requiredScopes?: string[] serviceId: string newScopes?: string[] + onConnect?: () => Promise | void } const SCOPE_DESCRIPTIONS: Record = { @@ -314,6 +315,7 @@ export function OAuthRequiredModal({ requiredScopes = [], serviceId, newScopes = [], + onConnect, }: OAuthRequiredModalProps) { const [error, setError] = useState(null) const { baseProvider } = parseProvider(provider) @@ -359,6 +361,12 @@ export function OAuthRequiredModal({ setError(null) try { + if (onConnect) { + await onConnect() + onClose() + return + } + const providerId = getProviderIdFromServiceId(serviceId) logger.info('Linking OAuth2:', { 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 378a9baed3..539d5a198a 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 @@ -1,12 +1,13 @@ 'use client' import { createElement, useCallback, useEffect, useMemo, useState } from 'react' -import { createLogger } from '@sim/logger' import { ExternalLink, Users } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button, Combobox } from '@/components/emcn/components' import { getSubscriptionStatus } from '@/lib/billing/client' import { getEnv, isTruthy } from '@/lib/core/config/env' import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' +import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -18,15 +19,14 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { 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 { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants' +import { CREDENTIAL_SET } from '@/executor/constants' 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 { useSubscriptionData } from '@/hooks/queries/subscription' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -const logger = createLogger('CredentialSelector') const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) interface CredentialSelectorProps { @@ -46,6 +46,8 @@ export function CredentialSelector({ previewValue, previewContextValues, }: CredentialSelectorProps) { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' const [showOAuthModal, setShowOAuthModal] = useState(false) const [editingValue, setEditingValue] = useState('') const [isEditing, setIsEditing] = useState(false) @@ -96,64 +98,64 @@ export function CredentialSelector({ data: credentials = [], isFetching: credentialsLoading, refetch: refetchCredentials, - } = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) + } = useOAuthCredentials(effectiveProviderId, { + enabled: Boolean(effectiveProviderId), + workspaceId, + workflowId: activeWorkflowId || undefined, + }) const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === 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( () => credentialSets.find((cs) => cs.id === selectedCredentialSetId), [credentialSets, selectedCredentialSetId] ) - const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet) + 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) { + 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(() => { if (selectedCredentialSet) return selectedCredentialSet.name - if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL if (selectedCredential) return selectedCredential.name - if (isForeign) return CREDENTIAL.FOREIGN_LABEL + if (inaccessibleCredentialName) return inaccessibleCredentialName return '' - }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign]) + }, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName]) const displayValue = isEditing ? editingValue : resolvedLabel - const invalidSelection = - !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) + useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId) const handleOpenChange = useCallback( (isOpen: boolean) => { @@ -195,8 +197,18 @@ export function CredentialSelector({ ) 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 { baseProvider } = parseProvider(providerName) @@ -251,23 +263,18 @@ export function CredentialSelector({ label: cred.name, 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({ - section: 'Personal Credential', - items: credentialItems, - }) - } else { - groups.push({ - section: 'Personal Credential', - items: [ - { - label: `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - }, - ], - }) - } + groups.push({ + section: 'Personal Credential', + items: credentialItems, + }) return { comboboxOptions: [], comboboxGroups: groups } } @@ -277,12 +284,13 @@ export function CredentialSelector({ value: cred.id, })) - if (credentials.length === 0) { - options.push({ - label: `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - }) - } + options.push({ + label: + credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + }) return { comboboxOptions: options, comboboxGroups: undefined } }, [ @@ -368,7 +376,7 @@ export function CredentialSelector({ } disabled={effectiveDisabled} editable={true} - filterOptions={!isForeign && !isForeignCredentialSet} + filterOptions={true} isLoading={credentialsLoading} overlayContent={overlayContent} className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''} @@ -380,15 +388,13 @@ export function CredentialSelector({ Additional permissions required - {!isForeign && ( - - )} + )} @@ -407,7 +413,11 @@ export function CredentialSelector({ ) } -function useCredentialRefreshTriggers(refetchCredentials: () => Promise) { +function useCredentialRefreshTriggers( + refetchCredentials: () => Promise, + providerId: string, + workspaceId: string +) { useEffect(() => { const refresh = () => { void refetchCredentials() @@ -425,12 +435,29 @@ function useCredentialRefreshTriggers(refetchCredentials: () => Promise } } + 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) window.addEventListener('pageshow', handlePageShow) + window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener) return () => { document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('pageshow', handlePageShow) + window.removeEventListener( + 'oauth-credentials-updated', + handleCredentialsUpdated as EventListener + ) } - }, [refetchCredentials]) + }, [providerId, workspaceId, refetchCredentials]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx index 32a6dd33c4..416e07950e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx @@ -9,6 +9,7 @@ import { PopoverSection, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state' import { usePersonalEnvironment, useWorkspaceEnvironment, @@ -168,7 +169,15 @@ export const EnvVarDropdown: React.FC = ({ }, [searchTerm]) 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?.() } @@ -302,7 +311,7 @@ export const EnvVarDropdown: React.FC = ({ }} > - Create environment variable + Create Secret ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx index 730f01b248..506eacc0dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx @@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth' 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 { 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 { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { getBlock } from '@/blocks/registry' @@ -125,8 +124,6 @@ export function FileSelectorInput({ const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId) - const selectorResolution = useMemo(() => { return resolveSelectorForSubBlock(subBlock, { workflowId: workflowIdFromUrl, @@ -168,7 +165,6 @@ export function FileSelectorInput({ const disabledReason = finalDisabled || - isForeignCredential || missingCredential || missingDomain || missingProject || diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx index 4be4a8da3f..25fec739bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' 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 { 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 { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import type { SubBlockConfig } from '@/blocks/types' @@ -47,10 +46,6 @@ export function FolderSelectorInput({ subBlock.canonicalParamId === 'copyDestinationId' || subBlock.id === 'copyDestinationFolder' || subBlock.id === 'manualCopyDestinationFolder' - const { isForeignCredential } = useForeignCredential( - effectiveProviderId, - (connectedCredential as string) || '' - ) // Central dependsOn gating const { finalDisabled } = useDependsOnGate(blockId, subBlock, { @@ -119,9 +114,7 @@ export function FolderSelectorInput({ selectorContext={ selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' } } - disabled={ - finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key - } + disabled={finalDisabled || missingCredential || !selectorResolution?.key} isPreview={isPreview} previewValue={previewValue ?? null} placeholder={subBlock.placeholder || 'Select folder'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx index e5b7c5d930..3df3acd464 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx @@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth' 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 { 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 { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { getBlock } from '@/blocks/registry' @@ -73,11 +72,6 @@ export function ProjectSelectorInput({ const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - - const { isForeignCredential } = useForeignCredential( - effectiveProviderId, - (connectedCredential as string) || '' - ) const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, @@ -123,7 +117,7 @@ export function ProjectSelectorInput({ subBlock={subBlock} selectorKey={selectorResolution.key} selectorContext={selectorResolution.context} - disabled={finalDisabled || isForeignCredential || missingCredential} + disabled={finalDisabled || missingCredential} isPreview={isPreview} previewValue={previewValue ?? null} placeholder={subBlock.placeholder || 'Select project'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx index bfb9dbe4f6..ee33b320a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx @@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth' 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 { 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 { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' @@ -87,8 +86,6 @@ export function SheetSelectorInput({ const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId) - const selectorResolution = useMemo(() => { return resolveSelectorForSubBlock(subBlock, { workflowId: workflowIdFromUrl, @@ -101,11 +98,7 @@ export function SheetSelectorInput({ const missingSpreadsheet = !normalizedSpreadsheetId const disabledReason = - finalDisabled || - isForeignCredential || - missingCredential || - missingSpreadsheet || - !selectorResolution?.key + finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key if (!selectorResolution?.key) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx index b99c26bff2..e3e4e21485 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx @@ -6,7 +6,6 @@ import { Tooltip } from '@/components/emcn' 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 { 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 { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import type { SubBlockConfig } from '@/blocks/types' @@ -85,11 +84,6 @@ export function SlackSelectorInput({ ? (effectiveBotToken as string) || '' : (effectiveCredential as string) || '' - const { isForeignCredential } = useForeignCredential( - effectiveProviderId, - (effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || '' - ) - useEffect(() => { const val = isPreview && previewValue !== undefined ? previewValue : storeValue if (typeof val === 'string') { @@ -99,7 +93,7 @@ export function SlackSelectorInput({ const requiresCredential = dependsOn.includes('credential') const missingCredential = !credential || credential.trim().length === 0 - const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential) + const shouldForceDisable = requiresCredential && missingCredential const context: SelectorContext = useMemo( () => ({ @@ -136,7 +130,7 @@ export function SlackSelectorInput({ subBlock={subBlock} selectorKey={config.selectorKey} selectorContext={context} - disabled={finalDisabled || shouldForceDisable || isForeignCredential} + disabled={finalDisabled || shouldForceDisable} isPreview={isPreview} previewValue={previewValue ?? null} placeholder={subBlock.placeholder || config.placeholder} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index 255d859079..773673457c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -1,6 +1,8 @@ import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { ExternalLink } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button, Combobox } from '@/components/emcn/components' +import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -11,8 +13,7 @@ import { parseProvider, } 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 { CREDENTIAL } from '@/executor/constants' -import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' +import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -64,6 +65,8 @@ export function ToolCredentialSelector({ serviceId, disabled = false, }: ToolCredentialSelectorProps) { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' const [showOAuthModal, setShowOAuthModal] = useState(false) const [editingInputValue, setEditingInputValue] = useState('') const [isEditing, setIsEditing] = useState(false) @@ -78,50 +81,58 @@ export function ToolCredentialSelector({ data: credentials = [], isFetching: credentialsLoading, refetch: refetchCredentials, - } = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) + } = useOAuthCredentials(effectiveProviderId, { + enabled: Boolean(effectiveProviderId), + workspaceId, + workflowId: activeWorkflowId || undefined, + }) const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === 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 [inaccessibleCredentialName, setInaccessibleCredentialName] = useState(null) - const hasForeignMeta = foreignCredentials.length > 0 - const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) + 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) { + onChange(data.credential.id) + } + setInaccessibleCredentialName(data.credential.displayName) + } + } catch { + // Ignore fetch errors + } + })() + + return () => { + cancelled = true + } + }, [selectedId, selectedCredential, credentialsLoading, workspaceId]) const resolvedLabel = useMemo(() => { if (selectedCredential) return selectedCredential.name - if (isForeign) return CREDENTIAL.FOREIGN_LABEL + if (inaccessibleCredentialName) return inaccessibleCredentialName return '' - }, [selectedCredential, isForeign]) + }, [selectedCredential, inaccessibleCredentialName]) const inputValue = isEditing ? editingInputValue : resolvedLabel - const invalidSelection = - Boolean(selectedId) && - !selectedCredential && - !hasForeignMeta && - !credentialsLoading && - !foreignMetaLoading - - useEffect(() => { - if (!invalidSelection) return - onChange('') - }, [invalidSelection, onChange]) - - useCredentialRefreshTriggers(refetchCredentials) + useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId) const handleOpenChange = useCallback( (isOpen: boolean) => { @@ -149,8 +160,18 @@ export function ToolCredentialSelector({ ) 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 options = credentials.map((cred) => ({ @@ -158,12 +179,13 @@ export function ToolCredentialSelector({ value: cred.id, })) - if (credentials.length === 0) { - options.push({ - label: `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - }) - } + options.push({ + label: + credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + }) return options }, [credentials, provider]) @@ -213,7 +235,7 @@ export function ToolCredentialSelector({ placeholder={effectiveLabel} disabled={disabled} editable={true} - filterOptions={!isForeign} + filterOptions={true} isLoading={credentialsLoading} overlayContent={overlayContent} className={selectedId ? 'pl-[28px]' : ''} @@ -225,15 +247,13 @@ export function ToolCredentialSelector({ Additional permissions required - {!isForeign && ( - - )} + )} @@ -252,7 +272,11 @@ export function ToolCredentialSelector({ ) } -function useCredentialRefreshTriggers(refetchCredentials: () => Promise) { +function useCredentialRefreshTriggers( + refetchCredentials: () => Promise, + providerId: string, + workspaceId: string +) { useEffect(() => { const refresh = () => { void refetchCredentials() @@ -270,12 +294,29 @@ function useCredentialRefreshTriggers(refetchCredentials: () => Promise } } + 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) window.addEventListener('pageshow', handlePageShow) + window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener) return () => { document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('pageshow', handlePageShow) + window.removeEventListener( + 'oauth-credentials-updated', + handleCredentialsUpdated as EventListener + ) } - }, [refetchCredentials]) + }, [providerId, workspaceId, refetchCredentials]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential.ts deleted file mode 100644 index 727b09da22..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' - -export function useForeignCredential( - provider: string | undefined, - credentialId: string | undefined -) { - const [isForeign, setIsForeign] = useState(false) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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 } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 66fa0ee164..a4f7648fa7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -255,6 +255,69 @@ 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, + 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 { workflows, activeWorkflowId, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 936e8f1146..87b042b695 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -473,7 +473,7 @@ function ConnectionsSection({ )} - {/* Environment Variables */} + {/* Secrets */} {envVars.length > 0 && (
- Environment Variables + Secrets void - registerCloseHandler?: (handler: (open: boolean) => void) => void } -export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { +export function ApiKeys({ onOpenChange }: ApiKeysProps) { const { data: session } = useSession() const userId = session?.user?.id const params = useParams() @@ -118,12 +117,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { onOpenChange?.(open) } - useEffect(() => { - if (registerCloseHandler) { - registerCloseHandler(handleModalClose) - } - }, [registerCloseHandler]) - useEffect(() => { if (shouldScrollToBottom && scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ 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 new file mode 100644 index 0000000000..c2e6960f34 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx @@ -0,0 +1,1692 @@ +'use client' + +import { createElement, useCallback, useEffect, useMemo, useState } from 'react' +import { createLogger } from '@sim/logger' +import { AlertTriangle, Check, Copy, Plus, RefreshCw, Search, Share2, Trash2 } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + Badge, + Button, + ButtonGroup, + ButtonGroupItem, + Combobox, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Textarea, + Tooltip, +} from '@/components/emcn' +import { Skeleton } from '@/components/ui' +import { useSession } from '@/lib/auth/auth-client' +import { cn } from '@/lib/core/utils/cn' +import { + clearPendingCredentialCreateRequest, + PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, + type PendingCredentialCreateRequest, + readPendingCredentialCreateRequest, +} from '@/lib/credentials/client-state' +import { + getCanonicalScopesForProvider, + getServiceConfigByProviderId, + type OAuthProvider, +} 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 { isValidEnvVarName } from '@/executor/constants' +import { + useCreateWorkspaceCredential, + useDeleteWorkspaceCredential, + useRemoveWorkspaceCredentialMember, + useUpdateWorkspaceCredential, + useUpsertWorkspaceCredentialMember, + useWorkspaceCredentialMembers, + useWorkspaceCredentials, + type WorkspaceCredential, + type WorkspaceCredentialRole, +} from '@/hooks/queries/credentials' +import { + usePersonalEnvironment, + useSavePersonalEnvironment, + useUpsertWorkspaceEnvironment, + useWorkspaceEnvironment, +} from '@/hooks/queries/environment' +import { + useConnectOAuthService, + useDisconnectOAuthService, + useOAuthConnections, +} from '@/hooks/queries/oauth-connections' +import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' + +const logger = createLogger('CredentialsManager') + +const roleOptions = [ + { value: 'member', label: 'Member' }, + { value: 'admin', label: 'Admin' }, +] as const + +type CreateCredentialType = 'oauth' | 'secret' +type SecretScope = 'workspace' | 'personal' +type SecretInputMode = 'single' | 'bulk' + +const createTypeOptions = [ + { value: 'oauth', label: 'OAuth Account' }, + { value: 'secret', label: 'Secret' }, +] as const + +interface ParsedEnvEntry { + key: string + value: string +} + +/** + * Parses `.env`-style text into key-value pairs. + * Supports `KEY=VALUE`, quoted values, comments (#), and blank lines. + */ +function parseEnvText(text: string): { entries: ParsedEnvEntry[]; errors: string[] } { + const entries: ParsedEnvEntry[] = [] + const errors: string[] = [] + const seenKeys = new Set() + + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const raw = lines[i].trim() + if (!raw || raw.startsWith('#')) continue + + const eqIndex = raw.indexOf('=') + if (eqIndex === -1) { + errors.push(`Line ${i + 1}: missing "=" separator`) + continue + } + + const key = raw.slice(0, eqIndex).trim() + let value = raw.slice(eqIndex + 1).trim() + + if (!key) { + errors.push(`Line ${i + 1}: empty key`) + continue + } + + if (!isValidEnvVarName(key)) { + errors.push(`Line ${i + 1}: "${key}" must contain only letters, numbers, and underscores`) + continue + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (!value) { + errors.push(`Line ${i + 1}: "${key}" has an empty value`) + continue + } + + if (seenKeys.has(key.toUpperCase())) { + errors.push(`Line ${i + 1}: duplicate key "${key}"`) + continue + } + + seenKeys.add(key.toUpperCase()) + entries.push({ key, value }) + } + + return { entries, errors } +} + +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' + return 'gray-secondary' +} + +function typeLabel(type: WorkspaceCredential['type']): string { + if (type === 'oauth') return 'OAuth' + if (type === 'env_workspace') return 'Workspace Secret' + return 'Personal Secret' +} + +function normalizeEnvKeyInput(raw: string): string { + const trimmed = raw.trim() + const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed) + return wrappedMatch ? wrappedMatch[1] : trimmed +} + +export function CredentialsManager() { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + + const [searchTerm, setSearchTerm] = useState('') + const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [memberRole, setMemberRole] = useState('admin') + const [memberUserId, setMemberUserId] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) + const [createType, setCreateType] = useState('oauth') + const [createSecretScope, setCreateSecretScope] = useState('workspace') + const [createDisplayName, setCreateDisplayName] = useState('') + const [createDescription, setCreateDescription] = useState('') + const [createEnvKey, setCreateEnvKey] = useState('') + const [createEnvValue, setCreateEnvValue] = useState('') + const [createOAuthProviderId, setCreateOAuthProviderId] = useState('') + const [createSecretInputMode, setCreateSecretInputMode] = useState('single') + const [createBulkText, setCreateBulkText] = useState('') + const [createError, setCreateError] = useState(null) + const [detailsError, setDetailsError] = useState(null) + const [selectedEnvValueDraft, setSelectedEnvValueDraft] = useState('') + const [isEditingEnvValue, setIsEditingEnvValue] = useState(false) + const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('') + const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('') + const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false) + const [copyIdSuccess, setCopyIdSuccess] = useState(false) + const { data: session } = useSession() + const currentUserId = session?.user?.id || '' + + const { + data: credentials = [], + isPending: credentialsLoading, + refetch: refetchCredentials, + } = useWorkspaceCredentials({ + workspaceId, + enabled: Boolean(workspaceId), + }) + + const { data: oauthConnections = [] } = useOAuthConnections() + const connectOAuthService = useConnectOAuthService() + const disconnectOAuthService = useDisconnectOAuthService() + const savePersonalEnvironment = useSavePersonalEnvironment() + const upsertWorkspaceEnvironment = useUpsertWorkspaceEnvironment() + const { data: personalEnvironment = {} } = usePersonalEnvironment() + const { data: workspaceEnvironmentData } = useWorkspaceEnvironment(workspaceId, { + select: (data) => data, + }) + + const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null) + const selectedCredential = useMemo( + () => credentials.find((credential) => credential.id === selectedCredentialId) || null, + [credentials, selectedCredentialId] + ) + + const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers( + selectedCredential?.id + ) + + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + const deleteCredential = useDeleteWorkspaceCredential() + const upsertMember = useUpsertWorkspaceCredentialMember() + const removeMember = useRemoveWorkspaceCredentialMember() + const oauthServiceNameByProviderId = useMemo( + () => new Map(oauthConnections.map((service) => [service.providerId, service.name])), + [oauthConnections] + ) + const resolveProviderLabel = (providerId?: string | null): string => { + if (!providerId) return '' + return oauthServiceNameByProviderId.get(providerId) || providerId + } + + const filteredCredentials = useMemo(() => { + if (!searchTerm.trim()) return credentials + const normalized = searchTerm.toLowerCase() + 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) + ) + }) + }, [credentials, searchTerm, oauthConnections]) + + const sortedCredentials = useMemo(() => { + return [...filteredCredentials].sort((a, b) => { + const aDate = new Date(a.updatedAt).getTime() + const bDate = new Date(b.updatedAt).getTime() + return bDate - aDate + }) + }, [filteredCredentials]) + + const oauthServiceOptions = useMemo( + () => + oauthConnections.map((service) => ({ + value: service.providerId, + label: service.name, + })), + [oauthConnections] + ) + + const activeMembers = useMemo( + () => members.filter((member) => member.status === 'active'), + [members] + ) + const adminMemberCount = useMemo( + () => activeMembers.filter((member) => member.role === 'admin').length, + [activeMembers] + ) + + const workspaceUserOptions = useMemo(() => { + const activeMemberUserIds = new Set(activeMembers.map((member) => member.userId)) + return (workspacePermissions?.users || []) + .filter((user) => !activeMemberUserIds.has(user.userId)) + .map((user) => ({ + value: user.userId, + label: user.name || user.email, + })) + }, [workspacePermissions?.users, activeMembers]) + + const selectedOAuthService = useMemo( + () => oauthConnections.find((service) => service.providerId === createOAuthProviderId) || null, + [oauthConnections, createOAuthProviderId] + ) + const createOAuthRequiredScopes = useMemo(() => { + if (!createOAuthProviderId) return [] + if (selectedOAuthService?.scopes?.length) { + return selectedOAuthService.scopes + } + return getCanonicalScopesForProvider(createOAuthProviderId) + }, [selectedOAuthService, createOAuthProviderId]) + const createSecretType = useMemo( + () => getSecretCredentialType(createSecretScope), + [createSecretScope] + ) + const selectedExistingEnvCredential = useMemo(() => { + if (createType !== 'secret' || createSecretInputMode !== 'single') return null + const envKey = normalizeEnvKeyInput(createEnvKey) + if (!envKey) return null + return ( + credentials.find( + (row) => + row.type === createSecretType && (row.envKey || '').toLowerCase() === envKey.toLowerCase() + ) ?? null + ) + }, [credentials, createEnvKey, createSecretType, createType, createSecretInputMode]) + + const crossScopeEnvConflict = useMemo(() => { + if (createType !== 'secret' || createSecretInputMode !== 'single') return null + if (createSecretScope !== 'personal') return null + const envKey = normalizeEnvKeyInput(createEnvKey) + if (!envKey) return null + return ( + credentials.find( + (row) => + row.type === 'env_workspace' && (row.envKey || '').toLowerCase() === envKey.toLowerCase() + ) ?? null + ) + }, [credentials, createEnvKey, createSecretScope, createType, createSecretInputMode]) + + const existingOAuthDisplayName = useMemo(() => { + if (createType !== 'oauth') return null + const name = createDisplayName.trim() + if (!name) return null + return ( + credentials.find( + (row) => row.type === 'oauth' && row.displayName.toLowerCase() === name.toLowerCase() + ) ?? null + ) + }, [credentials, createDisplayName, createType]) + const selectedEnvCurrentValue = useMemo(() => { + if (!selectedCredential || selectedCredential.type === 'oauth') return '' + const envKey = selectedCredential.envKey || '' + if (!envKey) return '' + + if (selectedCredential.type === 'env_workspace') { + return workspaceEnvironmentData?.workspace?.[envKey] || '' + } + + if (selectedCredential.envOwnerUserId && selectedCredential.envOwnerUserId !== currentUserId) { + return '' + } + + return personalEnvironment[envKey]?.value || workspaceEnvironmentData?.personal?.[envKey] || '' + }, [selectedCredential, workspaceEnvironmentData, personalEnvironment, currentUserId]) + const isEnvValueDirty = useMemo(() => { + if (!selectedCredential || selectedCredential.type === 'oauth') return false + return selectedEnvValueDraft !== selectedEnvCurrentValue + }, [selectedCredential, selectedEnvValueDraft, selectedEnvCurrentValue]) + + const isDescriptionDirty = useMemo(() => { + if (!selectedCredential) return false + return selectedDescriptionDraft !== (selectedCredential.description || '') + }, [selectedCredential, selectedDescriptionDraft]) + + const isDisplayNameDirty = useMemo(() => { + if (!selectedCredential) return false + return selectedDisplayNameDraft !== selectedCredential.displayName + }, [selectedCredential, selectedDisplayNameDraft]) + + const isDetailsDirty = isEnvValueDirty || isDescriptionDirty || isDisplayNameDirty + const [isSavingDetails, setIsSavingDetails] = useState(false) + + const handleSaveDetails = async () => { + if (!selectedCredential || !isSelectedAdmin || !isDetailsDirty) return + setDetailsError(null) + setIsSavingDetails(true) + + try { + if (isDisplayNameDirty || isDescriptionDirty) { + await updateCredential.mutateAsync({ + credentialId: selectedCredential.id, + ...(isDisplayNameDirty && selectedCredential.type === 'oauth' + ? { displayName: selectedDisplayNameDraft.trim() } + : {}), + ...(isDescriptionDirty ? { description: selectedDescriptionDraft.trim() || null } : {}), + }) + } + + if (isEnvValueDirty && canEditSelectedEnvValue) { + const envKey = selectedCredential.envKey || '' + if (envKey) { + if (selectedCredential.type === 'env_workspace') { + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { [envKey]: selectedEnvValueDraft }, + }) + } else { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + await savePersonalEnvironment.mutateAsync({ + variables: { ...personalVariables, [envKey]: selectedEnvValueDraft }, + }) + } + } + } + + await refetchCredentials() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to save changes' + setDetailsError(message) + logger.error('Failed to save credential details', error) + } finally { + setIsSavingDetails(false) + } + } + + useEffect(() => { + if (createType !== 'oauth') return + if (createOAuthProviderId || oauthConnections.length === 0) return + setCreateOAuthProviderId(oauthConnections[0]?.providerId || '') + }, [createType, createOAuthProviderId, oauthConnections]) + + useEffect(() => { + setCreateError(null) + }, [createOAuthProviderId]) + + const applyPendingCredentialCreateRequest = useCallback( + (request: PendingCredentialCreateRequest) => { + if (request.workspaceId !== workspaceId) { + return + } + + if (Date.now() - request.requestedAt > 15 * 60 * 1000) { + clearPendingCredentialCreateRequest() + return + } + + setShowCreateModal(true) + setShowCreateOAuthRequiredModal(false) + setCreateError(null) + setCreateDescription('') + setCreateEnvValue('') + + if (request.type === 'oauth') { + setCreateType('oauth') + setCreateOAuthProviderId(request.providerId) + setCreateDisplayName(request.displayName) + setCreateEnvKey('') + } else { + setCreateType('secret') + setCreateSecretScope(request.type === 'env_workspace' ? 'workspace' : 'personal') + setCreateOAuthProviderId('') + setCreateDisplayName('') + setCreateEnvKey(request.envKey || '') + } + + clearPendingCredentialCreateRequest() + }, + [workspaceId] + ) + + useEffect(() => { + if (!workspaceId) return + const request = readPendingCredentialCreateRequest() + if (!request) return + applyPendingCredentialCreateRequest(request) + }, [workspaceId, applyPendingCredentialCreateRequest]) + + useEffect(() => { + if (!workspaceId) return + + const handlePendingCreateRequest = (event: Event) => { + const request = (event as CustomEvent).detail + if (!request) return + applyPendingCredentialCreateRequest(request) + } + + window.addEventListener( + PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, + handlePendingCreateRequest as EventListener + ) + + return () => { + window.removeEventListener( + PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, + handlePendingCreateRequest as EventListener + ) + } + }, [workspaceId, applyPendingCredentialCreateRequest]) + + useEffect(() => { + if (!selectedCredential) { + setSelectedEnvValueDraft('') + setIsEditingEnvValue(false) + setSelectedDescriptionDraft('') + setSelectedDisplayNameDraft('') + return + } + + setDetailsError(null) + setSelectedDescriptionDraft(selectedCredential.description || '') + setSelectedDisplayNameDraft(selectedCredential.displayName) + + if (selectedCredential.type === 'oauth') { + setSelectedEnvValueDraft('') + setIsEditingEnvValue(false) + return + } + + const envKey = selectedCredential.envKey || '' + if (!envKey) { + setSelectedEnvValueDraft('') + return + } + + setSelectedEnvValueDraft(selectedEnvCurrentValue) + setIsEditingEnvValue(false) + }, [selectedCredential, selectedEnvCurrentValue]) + + const isSelectedAdmin = selectedCredential?.role === 'admin' + const selectedOAuthServiceConfig = useMemo(() => { + if ( + !selectedCredential || + selectedCredential.type !== 'oauth' || + !selectedCredential.providerId + ) { + return null + } + + return getServiceConfigByProviderId(selectedCredential.providerId) + }, [selectedCredential]) + + const resetCreateForm = () => { + setCreateType('oauth') + setCreateSecretScope('workspace') + setCreateSecretInputMode('single') + setCreateDisplayName('') + setCreateDescription('') + setCreateEnvKey('') + setCreateEnvValue('') + setCreateBulkText('') + setCreateOAuthProviderId('') + setCreateError(null) + setShowCreateOAuthRequiredModal(false) + } + + const handleSelectCredential = (credential: WorkspaceCredential) => { + setSelectedCredentialId(credential.id) + setDetailsError(null) + } + + const canEditSelectedEnvValue = useMemo(() => { + if (!selectedCredential || selectedCredential.type === 'oauth') return false + if (!isSelectedAdmin) return false + if (selectedCredential.type === 'env_workspace') return true + return Boolean( + selectedCredential.envOwnerUserId && + currentUserId && + selectedCredential.envOwnerUserId === currentUserId + ) + }, [selectedCredential, isSelectedAdmin, currentUserId]) + + const handleCreateCredential = async () => { + if (!workspaceId) return + setCreateError(null) + const normalizedDescription = createDescription.trim() + + try { + if (createType === 'oauth') { + if (!selectedOAuthService) { + setCreateError('Select an OAuth service before connecting.') + return + } + if (!createDisplayName.trim()) { + setCreateError('Display name is required.') + return + } + setShowCreateOAuthRequiredModal(true) + return + } + + if (createSecretInputMode === 'bulk') { + await handleBulkCreateSecrets() + return + } + + if (!createEnvKey.trim()) return + const normalizedEnvKey = normalizeEnvKeyInput(createEnvKey) + if (!isValidEnvVarName(normalizedEnvKey)) { + setCreateError('Secret key must contain only letters, numbers, and underscores.') + return + } + if (!createEnvValue.trim()) { + setCreateError('Secret value is required.') + return + } + + if (createSecretType === 'env_personal') { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + + await savePersonalEnvironment.mutateAsync({ + variables: { + ...personalVariables, + [normalizedEnvKey]: createEnvValue.trim(), + }, + }) + } else { + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { + ...workspaceVariables, + [normalizedEnvKey]: createEnvValue.trim(), + }, + }) + } + + const response = await createCredential.mutateAsync({ + workspaceId, + type: createSecretType, + envKey: normalizedEnvKey, + description: normalizedDescription || undefined, + }) + const credentialId = response?.credential?.id + if (credentialId) { + setSelectedCredentialId(credentialId) + } + + await refetchCredentials() + + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to create credential' + setCreateError(message) + logger.error('Failed to create credential', error) + } + } + + const handleBulkCreateSecrets = async () => { + if (!workspaceId) return + setCreateError(null) + + const { entries, errors } = parseEnvText(createBulkText) + if (errors.length > 0) { + setCreateError(errors.join('\n')) + return + } + + if (entries.length === 0) { + setCreateError('No valid KEY=VALUE pairs found. Add one per line, e.g. API_KEY=sk-abc123') + return + } + + try { + const newVars: Record = {} + for (const entry of entries) { + newVars[entry.key] = entry.value + } + + if (createSecretType === 'env_personal') { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + + await savePersonalEnvironment.mutateAsync({ + variables: { ...personalVariables, ...newVars }, + }) + } else { + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { ...workspaceVariables, ...newVars }, + }) + } + + let lastCredentialId: string | null = null + for (const entry of entries) { + const response = await createCredential.mutateAsync({ + workspaceId, + type: createSecretType, + envKey: entry.key, + }) + if (response?.credential?.id) { + lastCredentialId = response.credential.id + } + } + + if (lastCredentialId) { + setSelectedCredentialId(lastCredentialId) + } + + await refetchCredentials() + + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to create secrets' + setCreateError(message) + logger.error('Failed to bulk create secrets', error) + } + } + + const handleConnectOAuthService = async () => { + if (!selectedOAuthService) { + setCreateError('Select an OAuth service before connecting.') + return + } + + const displayName = createDisplayName.trim() + if (!displayName) { + setCreateError('Display name is required.') + return + } + + setCreateError(null) + try { + await fetch('/api/credentials/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + 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, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to start OAuth connection' + setCreateError(message) + logger.error('Failed to connect OAuth service', error) + } + } + + const handleDeleteCredential = async () => { + if (!selectedCredential) return + if (selectedCredential.type === 'oauth') { + await handleDisconnectSelectedCredential() + return + } + try { + await deleteCredential.mutateAsync(selectedCredential.id) + setSelectedCredentialId(null) + } catch (error) { + logger.error('Failed to delete credential', error) + } + } + + const [isPromoting, setIsPromoting] = useState(false) + const [isShareingWithWorkspace, setIsSharingWithWorkspace] = useState(false) + + const handleShareWithWorkspace = async () => { + if (!selectedCredential || !isSelectedAdmin) return + const usersToAdd = workspaceUserOptions + if (usersToAdd.length === 0) return + + setDetailsError(null) + setIsSharingWithWorkspace(true) + + try { + for (const user of usersToAdd) { + await upsertMember.mutateAsync({ + credentialId: selectedCredential.id, + userId: user.value, + role: 'member', + }) + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to share with workspace' + setDetailsError(message) + logger.error('Failed to share credential with workspace', error) + } finally { + setIsSharingWithWorkspace(false) + } + } + + const handlePromoteToWorkspace = async () => { + if (!selectedCredential || selectedCredential.type !== 'env_personal' || !workspaceId) return + const envKey = selectedCredential.envKey || '' + if (!envKey) return + + setDetailsError(null) + setIsPromoting(true) + + try { + const currentValue = + personalEnvironment[envKey]?.value || workspaceEnvironmentData?.personal?.[envKey] || '' + + if (!currentValue) { + setDetailsError('Cannot promote: secret value is empty.') + setIsPromoting(false) + return + } + + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { ...workspaceVariables, [envKey]: currentValue }, + }) + + const response = await createCredential.mutateAsync({ + workspaceId, + type: 'env_workspace', + envKey, + description: selectedCredential.description || undefined, + }) + + await deleteCredential.mutateAsync(selectedCredential.id) + + const newCredentialId = response?.credential?.id + if (newCredentialId) { + setSelectedCredentialId(newCredentialId) + } else { + setSelectedCredentialId(null) + } + + await refetchCredentials() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to promote secret' + setDetailsError(message) + logger.error('Failed to promote personal secret to workspace', error) + } finally { + setIsPromoting(false) + } + } + + const handleDisconnectSelectedCredential = async () => { + if (!selectedCredential || selectedCredential.type !== 'oauth' || !selectedCredential.accountId) + return + if (!selectedCredential.providerId) return + + try { + await disconnectOAuthService.mutateAsync({ + provider: selectedCredential.providerId.split('-')[0] || selectedCredential.providerId, + providerId: selectedCredential.providerId, + serviceId: selectedCredential.providerId, + accountId: selectedCredential.accountId, + }) + + setSelectedCredentialId(null) + await refetchCredentials() + window.dispatchEvent( + new CustomEvent('oauth-credentials-updated', { + detail: { providerId: selectedCredential.providerId, workspaceId }, + }) + ) + } catch (error) { + logger.error('Failed to disconnect credential account', error) + } + } + + const handleReconnectOAuth = async () => { + if ( + !selectedCredential || + selectedCredential.type !== 'oauth' || + !selectedCredential.providerId || + !workspaceId + ) + return + + setDetailsError(null) + + try { + await fetch('/api/credentials/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId, + providerId: selectedCredential.providerId, + displayName: selectedCredential.displayName, + description: selectedCredential.description || undefined, + credentialId: selectedCredential.id, + }), + }) + + window.sessionStorage.setItem( + 'sim.oauth-connect-pending', + JSON.stringify({ + displayName: selectedCredential.displayName, + providerId: selectedCredential.providerId, + preCount: credentials.filter((c) => c.type === 'oauth').length, + workspaceId, + reconnect: true, + }) + ) + + await connectOAuthService.mutateAsync({ + providerId: selectedCredential.providerId, + callbackURL: window.location.href, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to start reconnect' + setDetailsError(message) + logger.error('Failed to reconnect OAuth credential', error) + } + } + + const handleAddMember = async () => { + if (!selectedCredential || !memberUserId) return + try { + await upsertMember.mutateAsync({ + credentialId: selectedCredential.id, + userId: memberUserId, + role: memberRole, + }) + setMemberUserId('') + setMemberRole('admin') + } catch (error) { + logger.error('Failed to add credential member', error) + } + } + + const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { + if (!selectedCredential) return + const currentMember = activeMembers.find((member) => member.userId === userId) + if (currentMember?.role === role) return + try { + await upsertMember.mutateAsync({ + credentialId: selectedCredential.id, + userId, + role, + }) + } catch (error) { + logger.error('Failed to change member role', error) + } + } + + const handleRemoveMember = async (userId: string) => { + if (!selectedCredential) return + try { + await removeMember.mutateAsync({ + credentialId: selectedCredential.id, + userId, + }) + } catch (error) { + logger.error('Failed to remove credential member', error) + } + } + + return ( +
+
+
+
+ + setSearchTerm(event.target.value)} + placeholder='Search credentials...' + className='pl-[32px]' + /> +
+ +
+ +
+ {credentialsLoading ? ( +
+ + + +
+ ) : sortedCredentials.length === 0 ? ( +
+ No credentials available for this workspace. +
+ ) : ( +
+ {sortedCredentials.map((credential) => ( + + ))} +
+ )} +
+
+ +
+ {!selectedCredential ? ( +
+ Select a credential to manage members. +
+ ) : ( +
+
+
+
+ + {typeLabel(selectedCredential.type)} + + {selectedCredential.role && ( + + {selectedCredential.role} + + )} +
+ {isSelectedAdmin && ( +
+ + {selectedCredential.type === 'oauth' && ( + + + + + Reconnect account + + )} + {selectedCredential.type === 'env_personal' && ( + + + + + Promote to Workspace Secret + + )} + {selectedCredential.type === 'oauth' && + (workspaceUserOptions.length > 0 || isShareingWithWorkspace) && ( + + + + + + {isShareingWithWorkspace ? 'Sharing...' : 'Share with workspace'} + + + )} + + + + + + {selectedCredential.type === 'oauth' + ? 'Disconnect account' + : 'Delete credential'} + + +
+ )} +
+ + {selectedCredential.type === 'oauth' ? ( +
+
+
+ + + + + + Copy credential ID + +
+ setSelectedDisplayNameDraft(event.target.value)} + autoComplete='off' + disabled={!isSelectedAdmin} + className='mt-[6px]' + /> +
+
+ +