From f5b4ab0c8b7b8097ca911c0bec6701032723ba31 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 2 Jan 2026 12:21:24 -0800 Subject: [PATCH 01/21] progress on cred sets --- apps/sim/app/api/auth/oauth/token/route.ts | 47 ++- apps/sim/app/api/auth/oauth/utils.ts | 100 +++++- .../api/credential-sets/[id]/invite/route.ts | 165 +++++++++ .../api/credential-sets/[id]/members/route.ts | 114 ++++++ .../sim/app/api/credential-sets/[id]/route.ts | 154 ++++++++ .../api/credential-sets/invitations/route.ts | 52 +++ .../credential-sets/invite/[token]/route.ts | 150 ++++++++ .../api/credential-sets/memberships/route.ts | 39 ++ apps/sim/app/api/credential-sets/route.ts | 153 ++++++++ .../app/credential-account/[token]/page.tsx | 167 +++++++++ .../credential-selector.tsx | 128 ++++++- .../credential-sets/credential-sets.tsx | 335 ++++++++++++++++++ .../settings-modal/components/index.ts | 1 + .../settings-modal/settings-modal.tsx | 4 + apps/sim/background/webhook-execution.ts | 3 + apps/sim/blocks/blocks/webhook.ts | 2 + apps/sim/blocks/types.ts | 2 + apps/sim/executor/constants.ts | 2 + apps/sim/executor/execution/snapshot.ts | 3 +- apps/sim/executor/types.ts | 1 + apps/sim/executor/variables/resolver.ts | 2 + .../variables/resolvers/credential-set.ts | 42 +++ apps/sim/hooks/queries/credential-sets.ts | 163 +++++++++ apps/sim/hooks/use-webhook-management.ts | 23 +- apps/sim/lib/webhooks/processor.ts | 81 ++++- apps/sim/lib/webhooks/utils.server.ts | 102 ++++-- apps/sim/tools/index.ts | 21 +- apps/sim/tools/types.ts | 4 +- packages/db/schema.ts | 94 +++++ 29 files changed, 2078 insertions(+), 76 deletions(-) create mode 100644 apps/sim/app/api/credential-sets/[id]/invite/route.ts create mode 100644 apps/sim/app/api/credential-sets/[id]/members/route.ts create mode 100644 apps/sim/app/api/credential-sets/[id]/route.ts create mode 100644 apps/sim/app/api/credential-sets/invitations/route.ts create mode 100644 apps/sim/app/api/credential-sets/invite/[token]/route.ts create mode 100644 apps/sim/app/api/credential-sets/memberships/route.ts create mode 100644 apps/sim/app/api/credential-sets/route.ts create mode 100644 apps/sim/app/credential-account/[token]/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx create mode 100644 apps/sim/executor/variables/resolvers/credential-set.ts create mode 100644 apps/sim/hooks/queries/credential-sets.ts diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 603f5c6b0b..325f5d87ce 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -12,12 +12,17 @@ const logger = createLogger('OAuthTokenAPI') const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/ -const tokenRequestSchema = z.object({ - credentialId: z - .string({ required_error: 'Credential ID is required' }) - .min(1, 'Credential ID is required'), - workflowId: z.string().min(1, 'Workflow ID is required').nullish(), -}) +const tokenRequestSchema = z + .object({ + credentialId: z.string().min(1).optional(), + credentialAccountUserId: z.string().min(1).optional(), + providerId: z.string().min(1).optional(), + workflowId: z.string().min(1).nullish(), + }) + .refine( + (data) => data.credentialId || (data.credentialAccountUserId && data.providerId), + 'Either credentialId or (credentialAccountUserId + providerId) is required' + ) const tokenQuerySchema = z.object({ credentialId: z @@ -58,9 +63,31 @@ export async function POST(request: NextRequest) { ) } - const { credentialId, workflowId } = parseResult.data + const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data + + if (credentialAccountUserId && providerId) { + logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, { + credentialAccountUserId, + providerId, + }) + + const accessToken = await getOAuthToken(credentialAccountUserId, providerId) + if (!accessToken) { + return NextResponse.json( + { + error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`, + }, + { status: 404 } + ) + } + + return NextResponse.json({ accessToken }, { status: 200 }) + } + + if (!credentialId) { + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } - // We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it const authz = await authorizeCredentialUse(request, { credentialId, workflowId: workflowId ?? undefined, @@ -70,7 +97,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - // Fetch the credential as the owner to enforce ownership scoping const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) if (!credential) { @@ -78,7 +104,6 @@ export async function POST(request: NextRequest) { } try { - // Refresh the token if needed const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) let instanceUrl: string | undefined diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index cb9176e989..60e999242b 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { account, workflow } from '@sim/db/schema' +import { account, credentialSetMember, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, inArray } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { refreshOAuthToken } from '@/lib/oauth' @@ -335,3 +335,99 @@ export async function refreshTokenIfNeeded( throw error } } + +export interface CredentialSetCredential { + userId: string + credentialId: string + accessToken: string + providerId: string +} + +export async function getCredentialsForCredentialSet( + credentialSetId: string, + providerId: string +): Promise { + const members = await db + .select({ userId: credentialSetMember.userId }) + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, credentialSetId), + eq(credentialSetMember.status, 'active') + ) + ) + + if (members.length === 0) { + logger.warn(`No active members found for credential set ${credentialSetId}`) + return [] + } + + const userIds = members.map((m) => m.userId) + + const credentials = await db + .select({ + id: account.id, + userId: account.userId, + providerId: account.providerId, + accessToken: account.accessToken, + refreshToken: account.refreshToken, + accessTokenExpiresAt: account.accessTokenExpiresAt, + }) + .from(account) + .where(and(inArray(account.userId, userIds), eq(account.providerId, providerId))) + + const results: CredentialSetCredential[] = [] + + for (const cred of credentials) { + const now = new Date() + const tokenExpiry = cred.accessTokenExpiresAt + const shouldRefresh = + !!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now)) + + let accessToken = cred.accessToken + + if (shouldRefresh && cred.refreshToken) { + try { + const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken) + + if (refreshResult) { + accessToken = refreshResult.accessToken + + const updateData: Record = { + accessToken: refreshResult.accessToken, + accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000), + updatedAt: new Date(), + } + + if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) { + updateData.refreshToken = refreshResult.refreshToken + } + + await db.update(account).set(updateData).where(eq(account.id, cred.id)) + + logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`) + } + } catch (error) { + logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, { + error: error instanceof Error ? error.message : String(error), + }) + continue + } + } + + if (accessToken) { + results.push({ + userId: cred.userId, + credentialId: cred.id, + accessToken, + providerId, + }) + } + } + + logger.info( + `Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}` + ) + + return results +} diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts new file mode 100644 index 0000000000..147cf49363 --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -0,0 +1,165 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetInvitation, member } 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('CredentialSetInvite') + +const createInviteSchema = z.object({ + email: z.string().email().optional(), +}) + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + name: credentialSet.name, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function GET(req: 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 + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + const invitations = await db + .select() + .from(credentialSetInvitation) + .where(eq(credentialSetInvitation.credentialSetId, id)) + + return NextResponse.json({ invitations }) +} + +export async function POST(req: 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 result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin') { + return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) + } + + const body = await req.json() + const { email } = createInviteSchema.parse(body) + + const token = crypto.randomUUID() + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + const invitation = { + id: crypto.randomUUID(), + credentialSetId: id, + email: email || null, + token, + invitedBy: session.user.id, + status: 'pending' as const, + expiresAt, + createdAt: new Date(), + } + + await db.insert(credentialSetInvitation).values(invitation) + + const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/credential-account/${token}` + + logger.info('Created credential set invitation', { + credentialSetId: id, + invitationId: invitation.id, + userId: session.user.id, + }) + + return NextResponse.json({ + invitation: { + ...invitation, + inviteUrl, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error creating invitation', error) + return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) + } +} + +export async function DELETE(req: 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 + const { searchParams } = new URL(req.url) + const invitationId = searchParams.get('invitationId') + + if (!invitationId) { + return NextResponse.json({ error: 'invitationId is required' }, { status: 400 }) + } + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin') { + return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) + } + + await db + .update(credentialSetInvitation) + .set({ status: 'cancelled' }) + .where( + and( + eq(credentialSetInvitation.id, invitationId), + eq(credentialSetInvitation.credentialSetId, id) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error cancelling invitation', error) + return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts new file mode 100644 index 0000000000..4da50d0c89 --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSetMembers') + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function GET(req: 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 + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + const members = await db + .select({ + id: credentialSetMember.id, + userId: credentialSetMember.userId, + status: credentialSetMember.status, + joinedAt: credentialSetMember.joinedAt, + createdAt: credentialSetMember.createdAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(credentialSetMember) + .leftJoin(user, eq(credentialSetMember.userId, user.id)) + .where(eq(credentialSetMember.credentialSetId, id)) + + return NextResponse.json({ members }) +} + +export async function DELETE(req: 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 + const { searchParams } = new URL(req.url) + const memberId = searchParams.get('memberId') + + if (!memberId) { + return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) + } + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin') { + return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) + } + + const [memberToRemove] = await db + .select() + .from(credentialSetMember) + .where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id))) + .limit(1) + + if (!memberToRemove) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) + + logger.info('Removed member from credential set', { + credentialSetId: id, + memberId, + userId: session.user.id, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error removing member from credential set', error) + return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts new file mode 100644 index 0000000000..1b805b0f7f --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -0,0 +1,154 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, member } 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('CredentialSet') + +const updateCredentialSetSchema = z.object({ + name: z.string().trim().min(1).max(100).optional(), + description: z.string().max(500).nullable().optional(), +}) + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + name: credentialSet.name, + description: credentialSet.description, + createdBy: credentialSet.createdBy, + createdAt: credentialSet.createdAt, + updatedAt: credentialSet.updatedAt, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function GET(req: 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 + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + return NextResponse.json({ credentialSet: result.set }) +} + +export async function PUT(req: 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 result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin') { + return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) + } + + const body = await req.json() + const updates = updateCredentialSetSchema.parse(body) + + if (updates.name) { + const existingSet = await db + .select({ id: credentialSet.id }) + .from(credentialSet) + .where( + and( + eq(credentialSet.organizationId, result.set.organizationId), + eq(credentialSet.name, updates.name) + ) + ) + .limit(1) + + if (existingSet.length > 0 && existingSet[0].id !== id) { + return NextResponse.json( + { error: 'A credential set with this name already exists' }, + { status: 409 } + ) + } + } + + await db + .update(credentialSet) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(credentialSet.id, id)) + + const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1) + + return NextResponse.json({ credentialSet: updated }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error updating credential set', error) + return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) + } +} + +export async function DELETE(req: 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 result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin') { + return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) + } + + await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id)) + await db.delete(credentialSet).where(eq(credentialSet.id, id)) + + logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting credential set', error) + return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts new file mode 100644 index 0000000000..bd2b0d7afe --- /dev/null +++ b/apps/sim/app/api/credential-sets/invitations/route.ts @@ -0,0 +1,52 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, gt, or } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSetInvitations') + +export async function GET() { + const session = await getSession() + + if (!session?.user?.id || !session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const invitations = await db + .select({ + invitationId: credentialSetInvitation.id, + token: credentialSetInvitation.token, + status: credentialSetInvitation.status, + expiresAt: credentialSetInvitation.expiresAt, + createdAt: credentialSetInvitation.createdAt, + credentialSetId: credentialSet.id, + credentialSetName: credentialSet.name, + organizationId: organization.id, + organizationName: organization.name, + invitedByName: user.name, + invitedByEmail: user.email, + }) + .from(credentialSetInvitation) + .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) + .leftJoin(user, eq(credentialSetInvitation.invitedBy, user.id)) + .where( + and( + or( + eq(credentialSetInvitation.email, session.user.email), + eq(credentialSetInvitation.email, null) + ), + eq(credentialSetInvitation.status, 'pending'), + gt(credentialSetInvitation.expiresAt, new Date()) + ) + ) + + return NextResponse.json({ invitations }) + } catch (error) { + logger.error('Error fetching credential set invitations', error) + return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts new file mode 100644 index 0000000000..fc9d50817c --- /dev/null +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -0,0 +1,150 @@ +import { db } from '@sim/db' +import { + credentialSet, + credentialSetInvitation, + credentialSetMember, + organization, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSetInviteToken') + +export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params + + const [invitation] = await db + .select({ + id: credentialSetInvitation.id, + credentialSetId: credentialSetInvitation.credentialSetId, + email: credentialSetInvitation.email, + status: credentialSetInvitation.status, + expiresAt: credentialSetInvitation.expiresAt, + credentialSetName: credentialSet.name, + organizationId: credentialSet.organizationId, + organizationName: organization.name, + }) + .from(credentialSetInvitation) + .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) + .where(eq(credentialSetInvitation.token, token)) + .limit(1) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) + } + + if (new Date() > invitation.expiresAt) { + await db + .update(credentialSetInvitation) + .set({ status: 'expired' }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) + } + + return NextResponse.json({ + invitation: { + credentialSetName: invitation.credentialSetName, + organizationName: invitation.organizationName, + email: invitation.email, + }, + }) +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + try { + const [invitation] = await db + .select() + .from(credentialSetInvitation) + .where(eq(credentialSetInvitation.token, token)) + .limit(1) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) + } + + if (new Date() > invitation.expiresAt) { + await db + .update(credentialSetInvitation) + .set({ status: 'expired' }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) + } + + if (invitation.email && invitation.email !== session.user.email) { + return NextResponse.json({ error: 'Email does not match invitation' }, { status: 400 }) + } + + const existingMember = await db + .select() + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, invitation.credentialSetId), + eq(credentialSetMember.userId, session.user.id) + ) + ) + .limit(1) + + if (existingMember.length > 0) { + return NextResponse.json( + { error: 'Already a member of this credential set' }, + { status: 409 } + ) + } + + const now = new Date() + await db.insert(credentialSetMember).values({ + id: crypto.randomUUID(), + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + status: 'active', + joinedAt: now, + invitedBy: invitation.invitedBy, + createdAt: now, + updatedAt: now, + }) + + await db + .update(credentialSetInvitation) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByUserId: session.user.id, + }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + logger.info('Accepted credential set invitation', { + invitationId: invitation.id, + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + }) + + return NextResponse.json({ + success: true, + credentialSetId: invitation.credentialSetId, + }) + } catch (error) { + logger.error('Error accepting invitation', error) + return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts new file mode 100644 index 0000000000..33a60c2af5 --- /dev/null +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -0,0 +1,39 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSetMemberships') + +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: credentialSetMember.id, + status: credentialSetMember.status, + joinedAt: credentialSetMember.joinedAt, + credentialSetId: credentialSet.id, + credentialSetName: credentialSet.name, + credentialSetDescription: credentialSet.description, + organizationId: organization.id, + organizationName: organization.name, + }) + .from(credentialSetMember) + .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) + .where(eq(credentialSetMember.userId, session.user.id)) + + return NextResponse.json({ memberships }) + } catch (error) { + logger.error('Error fetching credential set memberships', error) + return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts new file mode 100644 index 0000000000..1ddeef24a9 --- /dev/null +++ b/apps/sim/app/api/credential-sets/route.ts @@ -0,0 +1,153 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, desc, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSets') + +const createCredentialSetSchema = z.object({ + organizationId: z.string().min(1), + name: z.string().trim().min(1).max(100), + description: z.string().max(500).optional(), +}) + +export async function GET(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const organizationId = searchParams.get('organizationId') + + if (!organizationId) { + return NextResponse.json({ error: 'organizationId is required' }, { status: 400 }) + } + + const membership = await db + .select({ id: member.id, role: member.role }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .limit(1) + + if (membership.length === 0) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const sets = await db + .select({ + id: credentialSet.id, + name: credentialSet.name, + description: credentialSet.description, + createdBy: credentialSet.createdBy, + createdAt: credentialSet.createdAt, + updatedAt: credentialSet.updatedAt, + creatorName: user.name, + creatorEmail: user.email, + }) + .from(credentialSet) + .leftJoin(user, eq(credentialSet.createdBy, user.id)) + .where(eq(credentialSet.organizationId, organizationId)) + .orderBy(desc(credentialSet.createdAt)) + + const setsWithCounts = await Promise.all( + sets.map(async (set) => { + const [memberCount] = await db + .select({ count: count() }) + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, set.id), + eq(credentialSetMember.status, 'active') + ) + ) + + return { + ...set, + memberCount: memberCount?.count ?? 0, + } + }) + ) + + return NextResponse.json({ credentialSets: setsWithCounts }) +} + +export async function POST(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { organizationId, name, description } = createCredentialSetSchema.parse(body) + + const membership = await db + .select({ id: member.id, role: member.role }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .limit(1) + + if (membership.length === 0 || membership[0].role !== 'admin') { + return NextResponse.json( + { error: 'Admin permissions required to create credential sets' }, + { status: 403 } + ) + } + + const orgExists = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (orgExists.length === 0) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const existingSet = await db + .select({ id: credentialSet.id }) + .from(credentialSet) + .where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name))) + .limit(1) + + if (existingSet.length > 0) { + return NextResponse.json( + { error: 'A credential set with this name already exists' }, + { status: 409 } + ) + } + + const now = new Date() + const newCredentialSet = { + id: crypto.randomUUID(), + organizationId, + name, + description: description || null, + createdBy: session.user.id, + createdAt: now, + updatedAt: now, + } + + await db.insert(credentialSet).values(newCredentialSet) + + logger.info('Created credential set', { + credentialSetId: newCredentialSet.id, + organizationId, + userId: session.user.id, + }) + + return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error creating credential set', error) + return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 }) + } +} diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx new file mode 100644 index 0000000000..1eb0e6382e --- /dev/null +++ b/apps/sim/app/credential-account/[token]/page.tsx @@ -0,0 +1,167 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { AlertCircle, CheckCircle2, Loader2, Shield } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { Button } from '@/components/emcn' +import { useSession } from '@/lib/auth/auth-client' + +interface InvitationInfo { + credentialSetName: string + organizationName: string + email: string | null +} + +export default function CredentialAccountInvitePage() { + const params = useParams() + const router = useRouter() + const token = params.token as string + + const { data: session, isPending: sessionLoading } = useSession() + + const [invitation, setInvitation] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [accepting, setAccepting] = useState(false) + const [accepted, setAccepted] = useState(false) + + useEffect(() => { + async function fetchInvitation() { + try { + const res = await fetch(`/api/credential-sets/invite/${token}`) + if (!res.ok) { + const data = await res.json() + setError(data.error || 'Failed to load invitation') + return + } + const data = await res.json() + setInvitation(data.invitation) + } catch { + setError('Failed to load invitation') + } finally { + setLoading(false) + } + } + + fetchInvitation() + }, [token]) + + const handleAccept = useCallback(async () => { + if (!session?.user?.id) { + router.push(`/login?callbackUrl=${encodeURIComponent(`/credential-account/${token}`)}`) + return + } + + setAccepting(true) + try { + const res = await fetch(`/api/credential-sets/invite/${token}`, { + method: 'POST', + }) + + if (!res.ok) { + const data = await res.json() + setError(data.error || 'Failed to accept invitation') + return + } + + setAccepted(true) + } catch { + setError('Failed to accept invitation') + } finally { + setAccepting(false) + } + }, [session?.user?.id, token, router]) + + if (loading || sessionLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+ +

+ Unable to load invitation +

+

{error}

+
+
+ ) + } + + if (accepted) { + return ( +
+
+ +

Welcome!

+

+ You've successfully joined {invitation?.credentialSetName}. Connect your OAuth + credentials in Settings → Integrations. +

+ +
+
+ ) + } + + return ( +
+
+
+ +

Join Credential Set

+

+ You've been invited to join{' '} + + {invitation?.credentialSetName} + {' '} + by {invitation?.organizationName} +

+
+ +
+ {session?.user ? ( + <> +

+ Logged in as{' '} + {session.user.email} +

+ + + ) : ( + <> +

+ Sign in or create an account to accept this invitation +

+ + + )} +
+ +

+ By joining, you agree to share your OAuth credentials with this credential set for use in + automated workflows. +

+
+
+ ) +} 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 d902200dd2..7533b838dc 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 @@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { ExternalLink } from 'lucide-react' +import { ExternalLink, Users } from 'lucide-react' import { Button, Combobox } from '@/components/emcn/components' +import { getSubscriptionStatus } from '@/lib/billing/client' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -15,12 +16,17 @@ 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 { useCredentialSets } from '@/hooks/queries/credential-sets' import { useOAuthCredentialDetail, 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 CREDENTIAL_SET_PREFIX = 'credentialSet:' + interface CredentialSelectorProps { blockId: string subBlock: SubBlockConfig @@ -45,6 +51,19 @@ export function CredentialSelector({ const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' + const supportsCredentialSets = subBlock.supportsCredentialSets || false + + const { data: organizationsData } = useOrganizations() + const { data: subscriptionData } = useSubscriptionData() + const activeOrganization = organizationsData?.activeOrganization + const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data) + const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise + const canUseCredentialSets = supportsCredentialSets && hasTeamPlan && !!activeOrganization?.id + + const { data: credentialSets = [] } = useCredentialSets( + activeOrganization?.id, + canUseCredentialSets + ) const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) const hasDependencies = dependsOn.length > 0 @@ -52,7 +71,12 @@ export function CredentialSelector({ const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue - const selectedId = typeof effectiveValue === 'string' ? effectiveValue : '' + const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : '' + const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET_PREFIX) + const selectedId = isCredentialSetSelected ? '' : rawSelectedId + const selectedCredentialSetId = isCredentialSetSelected + ? rawSelectedId.slice(CREDENTIAL_SET_PREFIX.length) + : '' const effectiveProviderId = useMemo( () => getProviderIdFromServiceId(serviceId) as OAuthProvider, @@ -87,11 +111,17 @@ export function CredentialSelector({ const hasForeignMeta = foreignCredentials.length > 0 const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) + const selectedCredentialSet = useMemo( + () => credentialSets.find((cs) => cs.id === selectedCredentialSetId), + [credentialSets, selectedCredentialSetId] + ) + const resolvedLabel = useMemo(() => { + if (selectedCredentialSet) return selectedCredentialSet.name if (selectedCredential) return selectedCredential.name if (isForeign) return 'Saved by collaborator' return '' - }, [selectedCredential, isForeign]) + }, [selectedCredentialSet, selectedCredential, isForeign]) useEffect(() => { if (!isEditing) { @@ -148,6 +178,15 @@ export function CredentialSelector({ [isPreview, setStoreValue] ) + const handleCredentialSetSelect = useCallback( + (credentialSetId: string) => { + if (isPreview) return + setStoreValue(`${CREDENTIAL_SET_PREFIX}${credentialSetId}`) + setIsEditing(false) + }, + [isPreview, setStoreValue] + ) + const handleAddCredential = useCallback(() => { setShowOAuthModal(true) }, []) @@ -176,7 +215,43 @@ export function CredentialSelector({ .join(' ') }, []) - const comboboxOptions = useMemo(() => { + const { comboboxOptions, comboboxGroups } = useMemo(() => { + if (canUseCredentialSets && credentialSets.length > 0) { + const groups = [] + + groups.push({ + section: 'Credential Sets', + items: credentialSets.map((cs) => ({ + label: `${cs.name} (${cs.memberCount} members)`, + value: `${CREDENTIAL_SET_PREFIX}${cs.id}`, + })), + }) + + const credentialItems = credentials.map((cred) => ({ + label: cred.name, + value: cred.id, + })) + + if (credentialItems.length > 0) { + groups.push({ + section: 'My Credentials', + items: credentialItems, + }) + } else if (!isCredentialSetSelected) { + groups.push({ + section: 'My Credentials', + items: [ + { + label: `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + }, + ], + }) + } + + return { comboboxOptions: [], comboboxGroups: groups } + } + const options = credentials.map((cred) => ({ label: cred.name, value: cred.id, @@ -189,14 +264,32 @@ export function CredentialSelector({ }) } - return options - }, [credentials, provider, getProviderName]) + return { comboboxOptions: options, comboboxGroups: undefined } + }, [ + credentials, + provider, + getProviderName, + canUseCredentialSets, + credentialSets, + isCredentialSetSelected, + ]) const selectedCredentialProvider = selectedCredential?.provider ?? provider const overlayContent = useMemo(() => { if (!inputValue) return null + if (isCredentialSetSelected && selectedCredentialSet) { + return ( +
+
+ +
+ {inputValue} +
+ ) + } + return (
@@ -205,7 +298,13 @@ export function CredentialSelector({ {inputValue}
) - }, [getProviderIcon, inputValue, selectedCredentialProvider]) + }, [ + getProviderIcon, + inputValue, + selectedCredentialProvider, + isCredentialSetSelected, + selectedCredentialSet, + ]) const handleComboboxChange = useCallback( (value: string) => { @@ -214,6 +313,16 @@ export function CredentialSelector({ return } + if (value.startsWith(CREDENTIAL_SET_PREFIX)) { + const credentialSetId = value.slice(CREDENTIAL_SET_PREFIX.length) + const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId) + if (matchedSet) { + setInputValue(matchedSet.name) + handleCredentialSetSelect(credentialSetId) + return + } + } + const matchedCred = credentials.find((c) => c.id === value) if (matchedCred) { setInputValue(matchedCred.name) @@ -224,15 +333,16 @@ export function CredentialSelector({ setIsEditing(true) setInputValue(value) }, - [credentials, handleAddCredential, handleSelect] + [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect] ) return (
+ + + +
+ ) +} + +export function CredentialSets() { + const { data: session } = useSession() + const { data: organizationsData } = useOrganizations() + const { data: subscriptionData } = useSubscriptionData() + + const activeOrganization = organizationsData?.activeOrganization + const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data) + const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise + const userRole = getUserRole(activeOrganization, session?.user?.email) + const isAdmin = userRole === 'admin' || userRole === 'owner' + const canManageCredentialSets = hasTeamPlan && isAdmin && !!activeOrganization?.id + + const { data: memberships = [], isPending: membershipsLoading } = useCredentialSetMemberships() + const { data: invitations = [], isPending: invitationsLoading } = useCredentialSetInvitations() + const { data: ownedSets = [], isPending: ownedSetsLoading } = useCredentialSets( + activeOrganization?.id, + canManageCredentialSets + ) + + const acceptInvitation = useAcceptCredentialSetInvitation() + const createCredentialSet = useCreateCredentialSet() + const createInvitation = useCreateCredentialSetInvitation() + + const [showCreateModal, setShowCreateModal] = useState(false) + const [showInviteModal, setShowInviteModal] = useState(false) + const [selectedSetId, setSelectedSetId] = useState(null) + const [newSetName, setNewSetName] = useState('') + const [newSetDescription, setNewSetDescription] = useState('') + const [inviteEmail, setInviteEmail] = useState('') + const [copiedLink, setCopiedLink] = useState(false) + + const handleAcceptInvitation = useCallback( + async (token: string) => { + try { + await acceptInvitation.mutateAsync(token) + } catch (error) { + logger.error('Failed to accept invitation', error) + } + }, + [acceptInvitation] + ) + + const handleCreateCredentialSet = useCallback(async () => { + if (!newSetName.trim() || !activeOrganization?.id) return + try { + await createCredentialSet.mutateAsync({ + organizationId: activeOrganization.id, + name: newSetName.trim(), + description: newSetDescription.trim() || undefined, + }) + setShowCreateModal(false) + setNewSetName('') + setNewSetDescription('') + } catch (error) { + logger.error('Failed to create credential set', error) + } + }, [newSetName, newSetDescription, activeOrganization?.id, createCredentialSet]) + + const handleCreateInvite = useCallback(async () => { + if (!selectedSetId) return + try { + const result = await createInvitation.mutateAsync({ + credentialSetId: selectedSetId, + email: inviteEmail.trim() || undefined, + }) + const inviteUrl = result.invitation?.inviteUrl + if (inviteUrl) { + await navigator.clipboard.writeText(inviteUrl) + setCopiedLink(true) + setTimeout(() => setCopiedLink(false), 2000) + } + setShowInviteModal(false) + setInviteEmail('') + setSelectedSetId(null) + } catch (error) { + logger.error('Failed to create invitation', error) + } + }, [selectedSetId, inviteEmail, createInvitation]) + + if (membershipsLoading || invitationsLoading) { + return + } + + const activeMemberships = memberships.filter((m) => m.status === 'active') + const hasNoContent = + invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0 + + return ( +
+ {hasNoContent && !canManageCredentialSets && ( +
+

+ You're not a member of any credential sets yet. +

+

+ When someone invites you to a credential set, it will appear here. +

+
+ )} + + {invitations.length > 0 && ( +
+ + {invitations.map((invitation) => ( +
+
+ + {invitation.credentialSetName} + + + {invitation.organizationName} + +
+ +
+ ))} +
+ )} + + {activeMemberships.length > 0 && ( +
+ + {activeMemberships.map((membership) => ( +
+
+ + {membership.credentialSetName} + + + {membership.organizationName} + +
+
+ ))} +
+ )} + + {canManageCredentialSets && ( +
+
+ + +
+ + {ownedSetsLoading ? ( + <> + + + + ) : ownedSets.length === 0 ? ( +
+

+ No credential sets created yet +

+
+ ) : ( + ownedSets.map((set) => ( +
+
+ + {set.name} + + + {set.memberCount} member{set.memberCount !== 1 ? 's' : ''} + +
+ +
+ )) + )} +
+ )} + + + + Create Credential Set + +
+
+ + setNewSetName(e.target.value)} + placeholder='e.g., Marketing Team' + /> +
+
+ + setNewSetDescription(e.target.value)} + placeholder='e.g., Credentials for marketing automations' + /> +
+
+
+ + + + +
+
+ + + + Invite to Credential Set + +
+
+ + setInviteEmail(e.target.value)} + placeholder='Leave empty for a shareable link' + /> +
+

+ An invite link will be copied to your clipboard. +

+
+
+ + + + +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts index 2c4d03228d..af86138a71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts @@ -1,6 +1,7 @@ export { ApiKeys } from './api-keys/api-keys' export { BYOK } from './byok/byok' export { Copilot } from './copilot/copilot' +export { CredentialSets } from './credential-sets/credential-sets' export { CustomTools } from './custom-tools/custom-tools' export { EnvironmentVariables } from './environment/environment' export { Files as FileUploads } from './files/files' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 1392a59601..37c876350e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -32,6 +32,7 @@ import { ApiKeys, BYOK, Copilot, + CredentialSets, CustomTools, EnvironmentVariables, FileUploads, @@ -63,6 +64,7 @@ type SettingsSection = | 'environment' | 'template-profile' | 'integrations' + | 'credential-sets' | 'apikeys' | 'byok' | 'files' @@ -98,6 +100,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [ const allNavigationItems: NavigationItem[] = [ { id: 'general', label: 'General', icon: Settings, section: 'account' }, { id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' }, + { id: 'credential-sets', label: 'Credential Sets', icon: Users, section: 'account' }, { id: 'subscription', label: 'Subscription', @@ -462,6 +465,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { registerCloseHandler={registerIntegrationsCloseHandler} /> )} + {activeSection === 'credential-sets' && } {activeSection === 'apikeys' && } {activeSection === 'files' && } {isBillingEnabled && activeSection === 'subscription' && } diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index e632ec2340..e895c1b405 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -94,6 +94,7 @@ export type WebhookExecutionPayload = { testMode?: boolean executionTarget?: 'deployed' | 'live' credentialId?: string + credentialAccountUserId?: string } export async function executeWebhookJob(payload: WebhookExecutionPayload) { @@ -240,6 +241,7 @@ async function executeWebhookJobInternal( useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + credentialAccountUserId: payload.credentialAccountUserId, workflowStateOverride: { blocks, edges, @@ -487,6 +489,7 @@ async function executeWebhookJobInternal( useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + credentialAccountUserId: payload.credentialAccountUserId, workflowStateOverride: { blocks, edges, diff --git a/apps/sim/blocks/blocks/webhook.ts b/apps/sim/blocks/blocks/webhook.ts index a7a9a1a737..493ce0850f 100644 --- a/apps/sim/blocks/blocks/webhook.ts +++ b/apps/sim/blocks/blocks/webhook.ts @@ -98,6 +98,7 @@ export const WebhookBlock: BlockConfig = { placeholder: 'Select Gmail account', condition: { field: 'webhookProvider', value: 'gmail' }, required: true, + supportsCredentialSets: true, }, { id: 'outlookCredential', @@ -114,6 +115,7 @@ export const WebhookBlock: BlockConfig = { placeholder: 'Select Microsoft account', condition: { field: 'webhookProvider', value: 'outlook' }, required: true, + supportsCredentialSets: true, }, { id: 'webhookConfig', diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 572593f677..7a9b61f86f 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -251,6 +251,8 @@ export interface SubBlockConfig { // OAuth specific properties - serviceId is the canonical identifier for OAuth services serviceId?: string requiredScopes?: string[] + // Whether this credential selector supports credential sets (for trigger blocks) + supportsCredentialSets?: boolean // File selector specific properties mimeType?: string // File upload specific properties diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index fd7c44436c..973db5b75e 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -112,6 +112,7 @@ export const REFERENCE = { LOOP: 'loop', PARALLEL: 'parallel', VARIABLE: 'variable', + CREDENTIAL_SET: 'credentialSet', }, } as const @@ -119,6 +120,7 @@ export const SPECIAL_REFERENCE_PREFIXES = [ REFERENCE.PREFIX.LOOP, REFERENCE.PREFIX.PARALLEL, REFERENCE.PREFIX.VARIABLE, + REFERENCE.PREFIX.CREDENTIAL_SET, ] as const export const LOOP_REFERENCE = { diff --git a/apps/sim/executor/execution/snapshot.ts b/apps/sim/executor/execution/snapshot.ts index dfa0d1cc37..1dc0a9ed23 100644 --- a/apps/sim/executor/execution/snapshot.ts +++ b/apps/sim/executor/execution/snapshot.ts @@ -16,12 +16,13 @@ export interface ExecutionMetadata { isClientSession?: boolean pendingBlocks?: string[] resumeFromSnapshot?: boolean + credentialAccountUserId?: string workflowStateOverride?: { blocks: Record edges: Edge[] loops?: Record parallels?: Record - deploymentVersionId?: string // ID of deployment version if this is deployed state + deploymentVersionId?: string } } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index f33b49195f..9c266e8b40 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -124,6 +124,7 @@ export interface ExecutionMetadata { isDebugSession?: boolean context?: ExecutionContext workflowConnections?: Array<{ source: string; target: string }> + credentialAccountUserId?: string status?: 'running' | 'paused' | 'completed' pausePoints?: string[] resumeChain?: { diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 980708931b..f4140b65f2 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -4,6 +4,7 @@ import type { ExecutionState, LoopScope } from '@/executor/execution/state' import type { ExecutionContext } from '@/executor/types' import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation' import { BlockResolver } from '@/executor/variables/resolvers/block' +import { CredentialSetResolver } from '@/executor/variables/resolvers/credential-set' import { EnvResolver } from '@/executor/variables/resolvers/env' import { LoopResolver } from '@/executor/variables/resolvers/loop' import { ParallelResolver } from '@/executor/variables/resolvers/parallel' @@ -27,6 +28,7 @@ export class VariableResolver { new LoopResolver(workflow), new ParallelResolver(workflow), new WorkflowResolver(workflowVariables), + new CredentialSetResolver(), new EnvResolver(), this.blockResolver, ] diff --git a/apps/sim/executor/variables/resolvers/credential-set.ts b/apps/sim/executor/variables/resolvers/credential-set.ts new file mode 100644 index 0000000000..00560b7749 --- /dev/null +++ b/apps/sim/executor/variables/resolvers/credential-set.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { parseReferencePath, REFERENCE } from '@/executor/constants' +import type { ResolutionContext, Resolver } from '@/executor/variables/resolvers/reference' + +const logger = createLogger('CredentialSetResolver') + +export const CREDENTIAL_SET_USER_PREFIX = 'credentialSetUser:' + +export class CredentialSetResolver implements Resolver { + canResolve(reference: string): boolean { + const parts = parseReferencePath(reference) + if (parts.length < 1) return false + + const type = parts[0] + return type === REFERENCE.PREFIX.CREDENTIAL_SET + } + + resolve(reference: string, context: ResolutionContext): any { + const parts = parseReferencePath(reference) + if (parts.length < 2) { + logger.warn('Invalid credential set reference - missing property', { reference }) + return undefined + } + + const [_, property] = parts + + if (property === 'currentUser') { + const credentialAccountUserId = context.executionContext.metadata?.credentialAccountUserId + if (!credentialAccountUserId) { + logger.warn( + 'credentialSet.currentUser referenced but no credentialAccountUserId in execution context', + { reference } + ) + return undefined + } + return `${CREDENTIAL_SET_USER_PREFIX}${credentialAccountUserId}` + } + + logger.warn('Unknown credential set property', { property, reference }) + return undefined + } +} diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts new file mode 100644 index 0000000000..cc61d2bac3 --- /dev/null +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -0,0 +1,163 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchJson } from '@/hooks/selectors/helpers' + +export interface CredentialSet { + id: string + name: string + description: string | null + createdBy: string + createdAt: string + updatedAt: string + creatorName: string | null + creatorEmail: string | null + memberCount: number +} + +export interface CredentialSetMembership { + membershipId: string + status: string + joinedAt: string | null + credentialSetId: string + credentialSetName: string + credentialSetDescription: string | null + organizationId: string + organizationName: string +} + +export interface CredentialSetInvitation { + invitationId: string + token: string + status: string + expiresAt: string + createdAt: string + credentialSetId: string + credentialSetName: string + organizationId: string + organizationName: string + invitedByName: string | null + invitedByEmail: string | null +} + +interface CredentialSetsResponse { + credentialSets?: CredentialSet[] +} + +interface MembershipsResponse { + memberships?: CredentialSetMembership[] +} + +interface InvitationsResponse { + invitations?: CredentialSetInvitation[] +} + +export const credentialSetKeys = { + all: ['credentialSets'] as const, + list: (organizationId?: string) => ['credentialSets', 'list', organizationId ?? 'none'] as const, + detail: (id?: string) => ['credentialSets', 'detail', id ?? 'none'] as const, + memberships: () => ['credentialSets', 'memberships'] as const, + invitations: () => ['credentialSets', 'invitations'] as const, +} + +export async function fetchCredentialSets(organizationId: string): Promise { + if (!organizationId) return [] + const data = await fetchJson('/api/credential-sets', { + searchParams: { organizationId }, + }) + return data.credentialSets ?? [] +} + +export function useCredentialSets(organizationId?: string, enabled = true) { + return useQuery({ + queryKey: credentialSetKeys.list(organizationId), + queryFn: () => fetchCredentialSets(organizationId ?? ''), + enabled: Boolean(organizationId) && enabled, + staleTime: 60 * 1000, + }) +} + +export function useCredentialSetMemberships() { + return useQuery({ + queryKey: credentialSetKeys.memberships(), + queryFn: async () => { + const data = await fetchJson('/api/credential-sets/memberships') + return data.memberships ?? [] + }, + staleTime: 60 * 1000, + }) +} + +export function useCredentialSetInvitations() { + return useQuery({ + queryKey: credentialSetKeys.invitations(), + queryFn: async () => { + const data = await fetchJson('/api/credential-sets/invitations') + return data.invitations ?? [] + }, + staleTime: 30 * 1000, + }) +} + +export function useAcceptCredentialSetInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (token: string) => { + const response = await fetch(`/api/credential-sets/invite/${token}`, { + method: 'POST', + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || 'Failed to accept invitation') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) + queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }) + }, + }) +} + +export function useCreateCredentialSet() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { organizationId: string; name: string; description?: string }) => { + const response = await fetch('/api/credential-sets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to create credential set') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) }) + }, + }) +} + +export function useCreateCredentialSetInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { credentialSetId: string; email?: string }) => { + const response = await fetch(`/api/credential-sets/${data.credentialSetId}/invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: data.email }), + }) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to create invitation') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.all }) + }, + }) +} diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 3e81c35ced..a10382989c 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -10,6 +10,8 @@ import { getTrigger, isTriggerValid } from '@/triggers' const logger = createLogger('useWebhookManagement') +const CREDENTIAL_SET_PREFIX = 'credentialSet:' + interface UseWebhookManagementProps { blockId: string triggerId?: string @@ -220,9 +222,18 @@ export function useWebhookManagement({ } const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + + const isCredentialSet = + selectedCredentialId && selectedCredentialId.startsWith(CREDENTIAL_SET_PREFIX) + const credentialSetId = isCredentialSet + ? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length) + : undefined + const credentialId = isCredentialSet ? undefined : selectedCredentialId + const webhookConfig = { ...(triggerConfig || {}), - ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), triggerId: effectiveTriggerId, } @@ -279,13 +290,21 @@ export function useWebhookManagement({ ): Promise => { const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const isCredentialSet = + selectedCredentialId && selectedCredentialId.startsWith(CREDENTIAL_SET_PREFIX) + const credentialSetId = isCredentialSet + ? selectedCredentialId.slice(CREDENTIAL_SET_PREFIX.length) + : undefined + const credentialId = isCredentialSet ? undefined : selectedCredentialId + const response = await fetch(`/api/webhooks/${webhookIdToUpdate}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerConfig: { ...triggerConfig, - ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), triggerId: effectiveTriggerId, }, }), diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index b197a8ef18..b6a941be31 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -13,6 +13,7 @@ import { validateMicrosoftTeamsSignature, verifyProviderWebhook, } from '@/lib/webhooks/utils.server' +import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils' import { executeWebhookJob } from '@/background/webhook-execution' import { REFERENCE } from '@/executor/constants' import { createEnvVarPattern } from '@/executor/utils/reference-validation' @@ -719,11 +720,12 @@ export async function queueWebhookExecution( } } - // Extract credentialId from webhook config for credential-based webhooks + // Extract credentialId or credentialSetId from webhook config const providerConfig = (foundWebhook.providerConfig as Record) || {} const credentialId = providerConfig.credentialId as string | undefined + const credentialSetId = providerConfig.credentialSetId as string | undefined - const payload = { + const basePayload = { webhookId: foundWebhook.id, workflowId: foundWorkflow.id, userId: foundWorkflow.userId, @@ -734,25 +736,72 @@ export async function queueWebhookExecution( blockId: foundWebhook.blockId, testMode: options.testMode, executionTarget: options.executionTarget, - ...(credentialId ? { credentialId } : {}), } - if (isTriggerDevEnabled) { - const handle = await tasks.trigger('webhook-execution', payload) - logger.info( - `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ - handle.id - } for ${foundWebhook.provider} webhook` + if (credentialSetId) { + const credentials = await getCredentialsForCredentialSet( + credentialSetId, + foundWebhook.provider ) - } else { - void executeWebhookJob(payload).catch((error) => { - logger.error(`[${options.requestId}] Direct webhook execution failed`, error) - }) + + if (credentials.length === 0) { + logger.warn( + `[${options.requestId}] No valid credentials found for credential set ${credentialSetId}, provider ${foundWebhook.provider}` + ) + return NextResponse.json( + { error: 'No valid credentials in credential set' }, + { status: 400 } + ) + } + logger.info( - `[${options.requestId}] Queued direct ${ - options.testMode ? 'TEST ' : '' - }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)` + `[${options.requestId}] Fan-out: Queuing ${credentials.length} executions for credential set ${credentialSetId}` ) + + for (const cred of credentials) { + const fanOutPayload = { + ...basePayload, + credentialId: cred.credentialId, + credentialAccountUserId: cred.userId, + } + + if (isTriggerDevEnabled) { + const handle = await tasks.trigger('webhook-execution', fanOutPayload) + logger.info( + `[${options.requestId}] Queued fan-out execution ${handle.id} for user ${cred.userId}` + ) + } else { + void executeWebhookJob(fanOutPayload).catch((error) => { + logger.error( + `[${options.requestId}] Direct fan-out execution failed for user ${cred.userId}`, + error + ) + }) + } + } + } else { + const payload = { + ...basePayload, + ...(credentialId ? { credentialId } : {}), + } + + if (isTriggerDevEnabled) { + const handle = await tasks.trigger('webhook-execution', payload) + logger.info( + `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ + handle.id + } for ${foundWebhook.provider} webhook` + ) + } else { + void executeWebhookJob(payload).catch((error) => { + logger.error(`[${options.requestId}] Direct webhook execution failed`, error) + }) + logger.info( + `[${options.requestId}] Queued direct ${ + options.testMode ? 'TEST ' : '' + }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)` + ) + } } if (foundWebhook.provider === 'microsoft-teams') { diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 49e69649df..063bc93140 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -2373,28 +2373,46 @@ export async function configureGmailPolling(webhookData: any, requestId: string) try { const providerConfig = (webhookData.providerConfig as Record) || {} const credentialId: string | undefined = providerConfig.credentialId + const credentialSetId: string | undefined = providerConfig.credentialSetId - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) - return false - } - - // Get userId from credential - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { + if (!credentialId && !credentialSetId) { logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + `[${requestId}] Missing credentialId or credentialSetId for Gmail webhook ${webhookData.id}` ) return false } - const effectiveUserId = rows[0].userId - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + let effectiveUserId: string | undefined + + if (credentialSetId) { + const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils') + const credentials = await getCredentialsForCredentialSet(credentialSetId, 'google-email') + if (credentials.length === 0) { + logger.error( + `[${requestId}] No Gmail credentials found in credential set ${credentialSetId}` + ) + return false + } + logger.info( + `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Gmail credentials` ) - return false + } else { + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + effectiveUserId = rows[0].userId + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false + } } const maxEmailsPerPoll = @@ -2414,8 +2432,9 @@ export async function configureGmailPolling(webhookData: any, requestId: string) .set({ providerConfig: { ...providerConfig, - userId: effectiveUserId, + ...(effectiveUserId ? { userId: effectiveUserId } : {}), ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), maxEmailsPerPoll, pollingInterval, markAsRead: providerConfig.markAsRead || false, @@ -2456,28 +2475,46 @@ export async function configureOutlookPolling( try { const providerConfig = (webhookData.providerConfig as Record) || {} const credentialId: string | undefined = providerConfig.credentialId + const credentialSetId: string | undefined = providerConfig.credentialSetId - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) - return false - } - - // Get userId from credential - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { + if (!credentialId && !credentialSetId) { logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + `[${requestId}] Missing credentialId or credentialSetId for Outlook webhook ${webhookData.id}` ) return false } - const effectiveUserId = rows[0].userId - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + let effectiveUserId: string | undefined + + if (credentialSetId) { + const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils') + const credentials = await getCredentialsForCredentialSet(credentialSetId, 'outlook') + if (credentials.length === 0) { + logger.error( + `[${requestId}] No Outlook credentials found in credential set ${credentialSetId}` + ) + return false + } + logger.info( + `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Outlook credentials` ) - return false + } else { + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + effectiveUserId = rows[0].userId + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false + } } const providerCfg = (webhookData.providerConfig as Record) || {} @@ -2489,8 +2526,9 @@ export async function configureOutlookPolling( .set({ providerConfig: { ...providerCfg, - userId: effectiveUserId, + ...(effectiveUserId ? { userId: effectiveUserId } : {}), ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), maxEmailsPerPoll: typeof providerCfg.maxEmailsPerPoll === 'string' ? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25 diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f92f451807..af918c8b4e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -5,6 +5,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { parseMcpToolId } from '@/lib/mcp/utils' import { isCustomTool, isMcpTool } from '@/executor/constants' import type { ExecutionContext } from '@/executor/types' +import { CREDENTIAL_SET_USER_PREFIX } from '@/executor/variables/resolvers/credential-set' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types' @@ -254,8 +255,24 @@ export async function executeTool( const baseUrl = getBaseUrl() // Prepare the token payload - const tokenPayload: OAuthTokenPayload = { - credentialId: contextParams.credential, + const tokenPayload: OAuthTokenPayload = {} + const credentialValue = contextParams.credential as string + + if (credentialValue.startsWith(CREDENTIAL_SET_USER_PREFIX)) { + const credentialAccountUserId = credentialValue.slice(CREDENTIAL_SET_USER_PREFIX.length) + const providerId = tool?.oauth?.provider + if (!providerId) { + throw new Error( + `Cannot resolve credential set user without providerId for tool ${toolId}` + ) + } + tokenPayload.credentialAccountUserId = credentialAccountUserId + tokenPayload.providerId = providerId + logger.info( + `[${requestId}] Using credential set user: ${credentialAccountUserId}, provider: ${providerId}` + ) + } else { + tokenPayload.credentialId = credentialValue } // Add workflowId if it exists in params, context, or executionContext diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 324f254e09..544a9c83e5 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -122,7 +122,9 @@ export interface TableRow { } export interface OAuthTokenPayload { - credentialId: string + credentialId?: string + credentialAccountUserId?: string + providerId?: string workflowId?: string } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 692fb11497..ac01cacddf 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1777,3 +1777,97 @@ export const usageLog = pgTable( workflowIdIdx: index('usage_log_workflow_id_idx').on(table.workflowId), }) ) + +export const credentialSet = pgTable( + 'credential_set', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + createdBy: text('created_by') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + organizationIdIdx: index('credential_set_organization_id_idx').on(table.organizationId), + createdByIdx: index('credential_set_created_by_idx').on(table.createdBy), + orgNameUnique: uniqueIndex('credential_set_org_name_unique').on( + table.organizationId, + table.name + ), + }) +) + +export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [ + 'active', + 'pending', + 'revoked', + 'credentials_missing', +]) + +export const credentialSetMember = pgTable( + 'credential_set_member', + { + id: text('id').primaryKey(), + credentialSetId: text('credential_set_id') + .notNull() + .references(() => credentialSet.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + status: credentialSetMemberStatusEnum('status').notNull().default('pending'), + joinedAt: timestamp('joined_at'), + invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + credentialSetIdIdx: index('credential_set_member_set_id_idx').on(table.credentialSetId), + userIdIdx: index('credential_set_member_user_id_idx').on(table.userId), + uniqueMembership: uniqueIndex('credential_set_member_unique').on( + table.credentialSetId, + table.userId + ), + statusIdx: index('credential_set_member_status_idx').on(table.status), + }) +) + +export const credentialSetInvitationStatusEnum = pgEnum('credential_set_invitation_status', [ + 'pending', + 'accepted', + 'expired', + 'cancelled', +]) + +export const credentialSetInvitation = pgTable( + 'credential_set_invitation', + { + id: text('id').primaryKey(), + credentialSetId: text('credential_set_id') + .notNull() + .references(() => credentialSet.id, { onDelete: 'cascade' }), + email: text('email'), + token: text('token').notNull().unique(), + invitedBy: text('invited_by') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + status: credentialSetInvitationStatusEnum('status').notNull().default('pending'), + expiresAt: timestamp('expires_at').notNull(), + acceptedAt: timestamp('accepted_at'), + acceptedByUserId: text('accepted_by_user_id').references(() => user.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + credentialSetIdIdx: index('credential_set_invitation_set_id_idx').on(table.credentialSetId), + tokenIdx: index('credential_set_invitation_token_idx').on(table.token), + statusIdx: index('credential_set_invitation_status_idx').on(table.status), + expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt), + }) +) From d908a287081df6e667edfcf0b5049bca46502d7b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Jan 2026 16:34:42 -0800 Subject: [PATCH 02/21] fix credential set system --- apps/sim/app/api/auth/oauth/token/route.ts | 27 +- apps/sim/app/api/auth/oauth/utils.ts | 11 +- .../api/credential-sets/[id]/members/route.ts | 71 ++- .../sim/app/api/credential-sets/[id]/route.ts | 2 + .../api/credential-sets/invitations/route.ts | 4 +- .../credential-sets/invite/[token]/route.ts | 34 ++ apps/sim/app/api/credential-sets/route.ts | 24 +- apps/sim/app/api/webhooks/[id]/route.ts | 65 ++- apps/sim/app/api/webhooks/route.ts | 151 ++++++ .../app/api/webhooks/trigger/[path]/route.ts | 204 +++++--- .../app/credential-account/[token]/page.tsx | 2 +- .../credential-selector.tsx | 31 +- .../credential-sets/credential-sets.tsx | 469 ++++++++++++++++-- apps/sim/background/webhook-execution.ts | 11 +- apps/sim/executor/constants.ts | 14 +- apps/sim/executor/variables/resolver.ts | 2 - .../variables/resolvers/credential-set.ts | 42 -- apps/sim/hooks/queries/credential-sets.ts | 68 ++- apps/sim/hooks/use-webhook-management.ts | 17 +- .../sim/lib/webhooks/gmail-polling-service.ts | 9 +- apps/sim/lib/webhooks/processor.ts | 169 ++++--- apps/sim/lib/webhooks/utils.server.ts | 439 ++++++++++++---- apps/sim/tools/index.ts | 22 +- apps/sim/triggers/airtable/webhook.ts | 1 + apps/sim/triggers/constants.ts | 1 + apps/sim/triggers/gmail/poller.ts | 23 + apps/sim/triggers/jira/utils.ts | 1 + .../triggers/microsoftteams/chat_webhook.ts | 1 + apps/sim/triggers/outlook/poller.ts | 17 + .../webflow/collection_item_changed.ts | 1 + .../webflow/collection_item_created.ts | 1 + .../webflow/collection_item_deleted.ts | 1 + apps/sim/triggers/webflow/form_submission.ts | 1 + packages/db/schema.ts | 7 +- 34 files changed, 1539 insertions(+), 404 deletions(-) delete mode 100644 apps/sim/executor/variables/resolvers/credential-set.ts diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 325f5d87ce..f5c8d7b617 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -71,17 +71,23 @@ export async function POST(request: NextRequest) { providerId, }) - const accessToken = await getOAuthToken(credentialAccountUserId, providerId) - if (!accessToken) { - return NextResponse.json( - { - error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`, - }, - { status: 404 } - ) - } + try { + const accessToken = await getOAuthToken(credentialAccountUserId, providerId) + if (!accessToken) { + return NextResponse.json( + { + error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`, + }, + { status: 404 } + ) + } - return NextResponse.json({ accessToken }, { status: 200 }) + return NextResponse.json({ accessToken }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get OAuth token' + logger.warn(`[${requestId}] OAuth token error: ${message}`) + return NextResponse.json({ error: message }, { status: 403 }) + } } if (!credentialId) { @@ -170,7 +176,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - // Get the credential from the database const credential = await getCredential(requestId, credentialId, auth.userId) if (!credential) { diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 60e999242b..08dd16fdff 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -105,10 +105,10 @@ export async function getOAuthToken(userId: string, providerId: string): Promise refreshToken: account.refreshToken, accessTokenExpiresAt: account.accessTokenExpiresAt, idToken: account.idToken, + scope: account.scope, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) - // Always use the most recently updated credential for this provider .orderBy(desc(account.updatedAt)) .limit(1) @@ -347,6 +347,8 @@ export async function getCredentialsForCredentialSet( credentialSetId: string, providerId: string ): Promise { + logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`) + const members = await db .select({ userId: credentialSetMember.userId }) .from(credentialSetMember) @@ -357,12 +359,15 @@ export async function getCredentialsForCredentialSet( ) ) + logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`) + if (members.length === 0) { logger.warn(`No active members found for credential set ${credentialSetId}`) return [] } const userIds = members.map((m) => m.userId) + logger.debug(`Member user IDs: ${userIds.join(', ')}`) const credentials = await db .select({ @@ -376,6 +381,10 @@ export async function getCredentialsForCredentialSet( .from(account) .where(and(inArray(account.userId, userIds), eq(account.providerId, providerId))) + logger.info( + `Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members` + ) + const results: CredentialSetCredential[] = [] for (const cred of credentials) { diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index 4da50d0c89..7f92d46fe1 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema' +import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetMembers') @@ -12,6 +13,8 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin .select({ id: credentialSet.id, organizationId: credentialSet.organizationId, + type: credentialSet.type, + providerId: credentialSet.providerId, }) .from(credentialSet) .where(eq(credentialSet.id, credentialSetId)) @@ -59,7 +62,58 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: .leftJoin(user, eq(credentialSetMember.userId, user.id)) .where(eq(credentialSetMember.credentialSetId, id)) - return NextResponse.json({ members }) + // Get credentials for all active members + const activeMembers = members.filter((m) => m.status === 'active') + const memberUserIds = activeMembers.map((m) => m.userId) + + let credentials: { userId: string; providerId: string; accountId: string }[] = [] + if (memberUserIds.length > 0) { + // If credential set is for a specific provider, filter by that provider + if (result.set.type === 'specific' && result.set.providerId) { + credentials = await db + .select({ + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where( + and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId)) + ) + } else { + credentials = await db + .select({ + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where(inArray(account.userId, memberUserIds)) + } + } + + // Group credentials by userId + const credentialsByUser = credentials.reduce( + (acc, cred) => { + if (!acc[cred.userId]) { + acc[cred.userId] = [] + } + acc[cred.userId].push({ + providerId: cred.providerId, + accountId: cred.accountId, + }) + return acc + }, + {} as Record + ) + + // Attach credentials to members + const membersWithCredentials = members.map((m) => ({ + ...m, + credentials: credentialsByUser[m.userId] || [], + })) + + return NextResponse.json({ members: membersWithCredentials }) } export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -106,6 +160,17 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i userId: session.user.id, }) + try { + const requestId = crypto.randomUUID().slice(0, 8) + const syncResult = await syncAllWebhooksForCredentialSet(id, requestId) + logger.info('Synced webhooks after member removed', { + credentialSetId: id, + ...syncResult, + }) + } catch (syncError) { + logger.error('Error syncing webhooks after member removed', syncError) + } + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error removing member from credential set', error) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index 1b805b0f7f..9eee99e135 100644 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -20,6 +20,8 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin organizationId: credentialSet.organizationId, name: credentialSet.name, description: credentialSet.description, + type: credentialSet.type, + providerId: credentialSet.providerId, createdBy: credentialSet.createdBy, createdAt: credentialSet.createdAt, updatedAt: credentialSet.updatedAt, diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts index bd2b0d7afe..ca33bab7af 100644 --- a/apps/sim/app/api/credential-sets/invitations/route.ts +++ b/apps/sim/app/api/credential-sets/invitations/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, gt, or } from 'drizzle-orm' +import { and, eq, gt, isNull, or } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -37,7 +37,7 @@ export async function GET() { and( or( eq(credentialSetInvitation.email, session.user.email), - eq(credentialSetInvitation.email, null) + isNull(credentialSetInvitation.email) ), eq(credentialSetInvitation.status, 'pending'), gt(credentialSetInvitation.expiresAt, new Date()) diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index fc9d50817c..a0f8b0e664 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -9,6 +9,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetInviteToken') @@ -133,12 +134,45 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok }) .where(eq(credentialSetInvitation.id, invitation.id)) + // Clean up all other pending invitations for the same credential set and email + // This prevents duplicate invites from showing up after accepting one + if (invitation.email) { + await db + .update(credentialSetInvitation) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByUserId: session.user.id, + }) + .where( + and( + eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), + eq(credentialSetInvitation.email, invitation.email), + eq(credentialSetInvitation.status, 'pending') + ) + ) + } + logger.info('Accepted credential set invitation', { invitationId: invitation.id, credentialSetId: invitation.credentialSetId, userId: session.user.id, }) + try { + const requestId = crypto.randomUUID().slice(0, 8) + const syncResult = await syncAllWebhooksForCredentialSet( + invitation.credentialSetId, + requestId + ) + logger.info('Synced webhooks after member joined', { + credentialSetId: invitation.credentialSetId, + ...syncResult, + }) + } catch (syncError) { + logger.error('Error syncing webhooks after member joined', syncError) + } + return NextResponse.json({ success: true, credentialSetId: invitation.credentialSetId, diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index 1ddeef24a9..4c73f8600e 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -8,11 +8,18 @@ import { getSession } from '@/lib/auth' const logger = createLogger('CredentialSets') -const createCredentialSetSchema = z.object({ - organizationId: z.string().min(1), - name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), -}) +const createCredentialSetSchema = z + .object({ + organizationId: z.string().min(1), + name: z.string().trim().min(1).max(100), + description: z.string().max(500).optional(), + type: z.enum(['all', 'specific']).default('all'), + providerId: z.string().min(1).optional(), + }) + .refine((data) => data.type !== 'specific' || data.providerId, { + message: 'providerId is required when type is specific', + path: ['providerId'], + }) export async function GET(req: Request) { const session = await getSession() @@ -43,6 +50,8 @@ export async function GET(req: Request) { id: credentialSet.id, name: credentialSet.name, description: credentialSet.description, + type: credentialSet.type, + providerId: credentialSet.providerId, createdBy: credentialSet.createdBy, createdAt: credentialSet.createdAt, updatedAt: credentialSet.updatedAt, @@ -85,7 +94,8 @@ export async function POST(req: Request) { try { const body = await req.json() - const { organizationId, name, description } = createCredentialSetSchema.parse(body) + const { organizationId, name, description, type, providerId } = + createCredentialSetSchema.parse(body) const membership = await db .select({ id: member.id, role: member.role }) @@ -129,6 +139,8 @@ export async function POST(req: Request) { organizationId, name, description: description || null, + type, + providerId: type === 'specific' ? providerId : null, createdBy: session.user.id, createdAt: now, updatedAt: now, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 6f7ffc3a3d..f5674ff36a 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateInteger } from '@/lib/core/security/input-validation' @@ -184,16 +184,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< hasFailedCountUpdate: failedCount !== undefined, }) - // Update the webhook + // Merge providerConfig to preserve credential-related fields + let finalProviderConfig = webhooks[0].webhook.providerConfig + if (providerConfig !== undefined) { + const existingConfig = (webhooks[0].webhook.providerConfig as Record) || {} + finalProviderConfig = { + ...resolvedProviderConfig, + credentialId: existingConfig.credentialId, + credentialSetId: existingConfig.credentialSetId, + userId: existingConfig.userId, + historyId: existingConfig.historyId, + lastCheckedTimestamp: existingConfig.lastCheckedTimestamp, + setupCompleted: existingConfig.setupCompleted, + externalId: existingConfig.externalId, + } + } + const updatedWebhook = await db .update(webhook) .set({ path: path !== undefined ? path : webhooks[0].webhook.path, provider: provider !== undefined ? provider : webhooks[0].webhook.provider, - providerConfig: - providerConfig !== undefined - ? resolvedProviderConfig - : webhooks[0].webhook.providerConfig, + providerConfig: finalProviderConfig, isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive, failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount, updatedAt: new Date(), @@ -276,13 +288,46 @@ export async function DELETE( } const foundWebhook = webhookData.webhook - const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions') - await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) - await db.delete(webhook).where(eq(webhook.id, id)) + const providerConfig = foundWebhook.providerConfig as Record | null + const credentialSetId = providerConfig?.credentialSetId as string | undefined + const blockId = providerConfig?.blockId as string | undefined + + if (credentialSetId && blockId) { + const allCredentialSetWebhooks = await db + .select() + .from(webhook) + .where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId))) + + const webhooksToDelete = allCredentialSetWebhooks.filter((w) => { + const config = w.providerConfig as Record | null + return config?.credentialSetId === credentialSetId + }) + + for (const w of webhooksToDelete) { + await cleanupExternalWebhook(w, webhookData.workflow, requestId) + } + + const idsToDelete = webhooksToDelete.map((w) => w.id) + for (const wId of idsToDelete) { + await db.delete(webhook).where(eq(webhook.id, wId)) + } + + logger.info( + `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`, + { + credentialSetId, + blockId, + deletedIds: idsToDelete, + } + ) + } else { + await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) + await db.delete(webhook).where(eq(webhook.id, id)) + logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + } - logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Error deleting webhook`, { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 62ad40db06..5456eb631b 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -262,6 +262,157 @@ export async function POST(request: NextRequest) { workflowRecord.workspaceId || undefined ) + // --- Credential Set Handling --- + // For credential sets, we fan out to create one webhook per credential at save time. + // This applies to all OAuth-based triggers, not just polling ones. + // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields + const rawCredentialId = (resolvedProviderConfig?.credentialId || + resolvedProviderConfig?.triggerCredentials) as string | undefined + const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined + + if (directCredentialSetId || rawCredentialId) { + const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants') + + const credentialSetId = + directCredentialSetId || + (rawCredentialId && isCredentialSetValue(rawCredentialId) + ? extractCredentialSetId(rawCredentialId) + : null) + + if (credentialSetId) { + logger.info( + `[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}` + ) + + const { getProviderIdFromServiceId } = await import('@/lib/oauth') + const { syncWebhooksForCredentialSet, configureGmailPolling, configureOutlookPolling } = + await import('@/lib/webhooks/utils.server') + + // Map provider to OAuth provider ID + const oauthProviderId = getProviderIdFromServiceId(provider) + + const { + credentialId: _cId, + triggerCredentials: _tCred, + credentialSetId: _csId, + ...baseProviderConfig + } = resolvedProviderConfig + + try { + const syncResult = await syncWebhooksForCredentialSet({ + workflowId, + blockId, + provider, + basePath: finalPath, + credentialSetId, + oauthProviderId, + providerConfig: baseProviderConfig, + requestId, + }) + + if (syncResult.webhooks.length === 0) { + logger.error( + `[${requestId}] No webhooks created for credential set - no valid credentials found` + ) + return NextResponse.json( + { + error: `No valid credentials found in credential set for ${provider}`, + details: 'Please ensure team members have connected their accounts', + }, + { status: 400 } + ) + } + + // Configure each new webhook (for providers that need configuration) + const pollingProviders = ['gmail', 'outlook'] + const needsConfiguration = pollingProviders.includes(provider) + + if (needsConfiguration) { + const configureFunc = + provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + const configureErrors: string[] = [] + + for (const wh of syncResult.webhooks) { + if (wh.isNew) { + // Fetch the webhook data for configuration + const webhookRows = await db + .select() + .from(webhook) + .where(eq(webhook.id, wh.id)) + .limit(1) + + if (webhookRows.length > 0) { + const success = await configureFunc(webhookRows[0], requestId) + if (!success) { + configureErrors.push( + `Failed to configure webhook for credential ${wh.credentialId}` + ) + logger.warn( + `[${requestId}] Failed to configure ${provider} polling for webhook ${wh.id}` + ) + } + } + } + } + + if ( + configureErrors.length > 0 && + configureErrors.length === syncResult.webhooks.length + ) { + // All configurations failed - roll back + logger.error(`[${requestId}] All webhook configurations failed, rolling back`) + for (const wh of syncResult.webhooks) { + await db.delete(webhook).where(eq(webhook.id, wh.id)) + } + return NextResponse.json( + { + error: `Failed to configure ${provider} polling`, + details: 'Please check account permissions and try again', + }, + { status: 500 } + ) + } + } + + logger.info( + `[${requestId}] Successfully synced ${syncResult.webhooks.length} webhooks for credential set ${credentialSetId}` + ) + + // Return the first webhook as the "primary" for the UI + // The UI will query by credentialSetId to get all of them + const primaryWebhookRows = await db + .select() + .from(webhook) + .where(eq(webhook.id, syncResult.webhooks[0].id)) + .limit(1) + + return NextResponse.json( + { + webhook: primaryWebhookRows[0], + credentialSetInfo: { + credentialSetId, + totalWebhooks: syncResult.webhooks.length, + created: syncResult.created, + updated: syncResult.updated, + deleted: syncResult.deleted, + }, + }, + { status: syncResult.created > 0 ? 201 : 200 } + ) + } catch (err) { + logger.error(`[${requestId}] Error syncing webhooks for credential set`, err) + return NextResponse.json( + { + error: `Failed to configure ${provider} webhook`, + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } + } + } + // --- End Credential Set Handling --- + // Create external subscriptions before saving to DB to prevent orphaned records let externalSubscriptionId: string | undefined let externalSubscriptionCreated = false diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 9cdff87dc5..a7bafc236a 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { checkWebhookPreprocessing, - findWebhookAndWorkflow, + findAllWebhooksForPath, handleProviderChallenges, handleProviderReachabilityTest, parseWebhookBody, @@ -86,109 +86,151 @@ export async function POST( return challengeResponse } - const findResult = await findWebhookAndWorkflow({ requestId, path }) + // Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path) + const webhooksForPath = await findAllWebhooksForPath({ requestId, path }) - if (!findResult) { + if (webhooksForPath.length === 0) { logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`) - return new NextResponse('Not Found', { status: 404 }) } - const { webhook: foundWebhook, workflow: foundWorkflow } = findResult + // Process each webhook + // For credential sets with shared paths, each webhook represents a different credential + const responses: NextResponse[] = [] + + for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { + // Log HubSpot webhook details for debugging + if (foundWebhook.provider === 'hubspot') { + const events = Array.isArray(body) ? body : [body] + const firstEvent = events[0] + + logger.info(`[${requestId}] HubSpot webhook received`, { + path, + subscriptionType: firstEvent?.subscriptionType, + objectId: firstEvent?.objectId, + portalId: firstEvent?.portalId, + webhookId: foundWebhook.id, + workflowId: foundWorkflow.id, + triggerId: foundWebhook.providerConfig?.triggerId, + eventCount: events.length, + }) + } - // Log HubSpot webhook details for debugging - if (foundWebhook.provider === 'hubspot') { - const events = Array.isArray(body) ? body : [body] - const firstEvent = events[0] + const authError = await verifyProviderAuth( + foundWebhook, + foundWorkflow, + request, + rawBody, + requestId + ) + if (authError) { + // For multi-webhook, log and continue to next webhook + if (webhooksForPath.length > 1) { + logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`) + continue + } + return authError + } - logger.info(`[${requestId}] HubSpot webhook received`, { - path, - subscriptionType: firstEvent?.subscriptionType, - objectId: firstEvent?.objectId, - portalId: firstEvent?.portalId, - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId: foundWebhook.providerConfig?.triggerId, - eventCount: events.length, - }) - } + const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId) + if (reachabilityResponse) { + // Reachability test should return immediately for the first webhook + return reachabilityResponse + } - const authError = await verifyProviderAuth( - foundWebhook, - foundWorkflow, - request, - rawBody, - requestId - ) - if (authError) { - return authError - } + let preprocessError: NextResponse | null = null + try { + preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) + if (preprocessError) { + if (webhooksForPath.length > 1) { + logger.warn( + `[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next` + ) + continue + } + return preprocessError + } + } catch (error) { + logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + webhookId: foundWebhook.id, + workflowId: foundWorkflow.id, + }) - const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId) - if (reachabilityResponse) { - return reachabilityResponse - } + if (webhooksForPath.length > 1) { + continue + } - let preprocessError: NextResponse | null = null - try { - preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) - if (preprocessError) { - return preprocessError - } - } catch (error) { - logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - }) + if (foundWebhook.provider === 'microsoft-teams') { + return NextResponse.json( + { + type: 'message', + text: 'An unexpected error occurred during preprocessing', + }, + { status: 500 } + ) + } - if (foundWebhook.provider === 'microsoft-teams') { return NextResponse.json( - { - type: 'message', - text: 'An unexpected error occurred during preprocessing', - }, + { error: 'An unexpected error occurred during preprocessing' }, { status: 500 } ) } - return NextResponse.json( - { error: 'An unexpected error occurred during preprocessing' }, - { status: 500 } - ) - } - - if (foundWebhook.blockId) { - const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) - if (!blockExists) { - logger.info( - `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}` - ) - return new NextResponse('Trigger block not found in deployment', { status: 404 }) + if (foundWebhook.blockId) { + const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) + if (!blockExists) { + logger.info( + `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}` + ) + if (webhooksForPath.length > 1) { + continue + } + return new NextResponse('Trigger block not found in deployment', { status: 404 }) + } } - } - if (foundWebhook.provider === 'stripe') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const eventTypes = providerConfig.eventTypes + if (foundWebhook.provider === 'stripe') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type + if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { + const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Stripe event type '${eventType}' not in allowed list, skipping execution` - ) - return new NextResponse('Event type filtered', { status: 200 }) + if (eventType && !eventTypes.includes(eventType)) { + logger.info( + `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${foundWebhook.id}, skipping` + ) + continue + } } } + + const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + path, + testMode: false, + executionTarget: 'deployed', + }) + responses.push(response) } - return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { - requestId, - path, - testMode: false, - executionTarget: 'deployed', + // Return the last successful response, or a combined response for multiple webhooks + if (responses.length === 0) { + return new NextResponse('No webhooks processed successfully', { status: 500 }) + } + + if (responses.length === 1) { + return responses[0] + } + + // For multiple webhooks, return success if at least one succeeded + logger.info( + `[${requestId}] Processed ${responses.length} webhooks for path: ${path} (credential set fan-out)` + ) + return NextResponse.json({ + success: true, + webhooksProcessed: responses.length, }) } diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx index 1eb0e6382e..d363f30013 100644 --- a/apps/sim/app/credential-account/[token]/page.tsx +++ b/apps/sim/app/credential-account/[token]/page.tsx @@ -104,7 +104,7 @@ export default function CredentialAccountInvitePage() { You've successfully joined {invitation?.credentialSetName}. Connect your OAuth credentials in Settings → Integrations.

-
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 7533b838dc..ca82ee94a9 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 @@ -16,6 +16,7 @@ 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_SET } from '@/executor/constants' import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOrganizations } from '@/hooks/queries/organization' @@ -25,8 +26,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CredentialSelector') -const CREDENTIAL_SET_PREFIX = 'credentialSet:' - interface CredentialSelectorProps { blockId: string subBlock: SubBlockConfig @@ -72,10 +71,10 @@ export function CredentialSelector({ const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : '' - const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET_PREFIX) + const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET.PREFIX) const selectedId = isCredentialSetSelected ? '' : rawSelectedId const selectedCredentialSetId = isCredentialSetSelected - ? rawSelectedId.slice(CREDENTIAL_SET_PREFIX.length) + ? rawSelectedId.slice(CREDENTIAL_SET.PREFIX.length) : '' const effectiveProviderId = useMemo( @@ -181,7 +180,7 @@ export function CredentialSelector({ const handleCredentialSetSelect = useCallback( (credentialSetId: string) => { if (isPreview) return - setStoreValue(`${CREDENTIAL_SET_PREFIX}${credentialSetId}`) + setStoreValue(`${CREDENTIAL_SET.PREFIX}${credentialSetId}`) setIsEditing(false) }, [isPreview, setStoreValue] @@ -216,14 +215,18 @@ export function CredentialSelector({ }, []) const { comboboxOptions, comboboxGroups } = useMemo(() => { - if (canUseCredentialSets && credentialSets.length > 0) { + const filteredCredentialSets = credentialSets.filter( + (cs) => cs.type === 'all' || cs.providerId === effectiveProviderId + ) + + if (canUseCredentialSets && filteredCredentialSets.length > 0) { const groups = [] groups.push({ section: 'Credential Sets', - items: credentialSets.map((cs) => ({ - label: `${cs.name} (${cs.memberCount} members)`, - value: `${CREDENTIAL_SET_PREFIX}${cs.id}`, + items: filteredCredentialSets.map((cs) => ({ + label: cs.name, + value: `${CREDENTIAL_SET.PREFIX}${cs.id}`, })), }) @@ -237,7 +240,7 @@ export function CredentialSelector({ section: 'My Credentials', items: credentialItems, }) - } else if (!isCredentialSetSelected) { + } else { groups.push({ section: 'My Credentials', items: [ @@ -268,10 +271,10 @@ export function CredentialSelector({ }, [ credentials, provider, + effectiveProviderId, getProviderName, canUseCredentialSets, credentialSets, - isCredentialSetSelected, ]) const selectedCredentialProvider = selectedCredential?.provider ?? provider @@ -313,8 +316,8 @@ export function CredentialSelector({ return } - if (value.startsWith(CREDENTIAL_SET_PREFIX)) { - const credentialSetId = value.slice(CREDENTIAL_SET_PREFIX.length) + if (value.startsWith(CREDENTIAL_SET.PREFIX)) { + const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length) const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId) if (matchedSet) { setInputValue(matchedSet.name) @@ -353,7 +356,7 @@ export function CredentialSelector({ filterOptions={true} isLoading={credentialsLoading} overlayContent={overlayContent} - className={selectedId ? 'pl-[28px]' : ''} + className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''} /> {needsUpdate && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx index 69ff2b2dd7..8f38c485c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx @@ -1,10 +1,14 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { Check, Copy, Loader2, Plus } from 'lucide-react' +import { ArrowLeft, Check, Copy, Loader2, Plus, Trash2, User } from 'lucide-react' import { + Avatar, + AvatarFallback, + AvatarImage, Button, + Combobox, Input, Label, Modal, @@ -16,14 +20,19 @@ import { import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client' +import { OAUTH_PROVIDERS } from '@/lib/oauth' import { getUserRole } from '@/lib/workspaces/organization' import { + type CredentialSet, + type CredentialSetType, useAcceptCredentialSetInvitation, useCreateCredentialSet, useCreateCredentialSetInvitation, useCredentialSetInvitations, + useCredentialSetMembers, useCredentialSetMemberships, useCredentialSets, + useRemoveCredentialSetMember, } from '@/hooks/queries/credential-sets' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' @@ -66,10 +75,60 @@ export function CredentialSets() { const [showCreateModal, setShowCreateModal] = useState(false) const [showInviteModal, setShowInviteModal] = useState(false) const [selectedSetId, setSelectedSetId] = useState(null) + const [viewingSet, setViewingSet] = useState(null) const [newSetName, setNewSetName] = useState('') const [newSetDescription, setNewSetDescription] = useState('') + const [newSetType, setNewSetType] = useState('all') + const [newSetProviderId, setNewSetProviderId] = useState('') const [inviteEmail, setInviteEmail] = useState('') const [copiedLink, setCopiedLink] = useState(false) + const [generatedInviteUrl, setGeneratedInviteUrl] = useState(null) + + const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id) + const removeMember = useRemoveCredentialSetMember() + + const providerOptions = useMemo(() => { + const options: { label: string; value: string }[] = [] + for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) { + if (provider.services) { + for (const [, service] of Object.entries(provider.services)) { + options.push({ + label: service.name, + value: service.providerId, + }) + } + } + } + return options.sort((a, b) => a.label.localeCompare(b.label)) + }, []) + + const getProviderName = useCallback((providerId: string) => { + for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) { + if (provider.services) { + for (const [, service] of Object.entries(provider.services)) { + if (service.providerId === providerId) { + return service.name + } + } + } + } + return providerId + }, []) + + const handleRemoveMember = useCallback( + async (memberId: string) => { + if (!viewingSet) return + try { + await removeMember.mutateAsync({ + credentialSetId: viewingSet.id, + memberId, + }) + } catch (error) { + logger.error('Failed to remove member', error) + } + }, + [viewingSet, removeMember] + ) const handleAcceptInvitation = useCallback( async (token: string) => { @@ -84,19 +143,31 @@ export function CredentialSets() { const handleCreateCredentialSet = useCallback(async () => { if (!newSetName.trim() || !activeOrganization?.id) return + if (newSetType === 'specific' && !newSetProviderId) return try { await createCredentialSet.mutateAsync({ organizationId: activeOrganization.id, name: newSetName.trim(), description: newSetDescription.trim() || undefined, + type: newSetType, + providerId: newSetType === 'specific' ? newSetProviderId : undefined, }) setShowCreateModal(false) setNewSetName('') setNewSetDescription('') + setNewSetType('all') + setNewSetProviderId('') } catch (error) { logger.error('Failed to create credential set', error) } - }, [newSetName, newSetDescription, activeOrganization?.id, createCredentialSet]) + }, [ + newSetName, + newSetDescription, + newSetType, + newSetProviderId, + activeOrganization?.id, + createCredentialSet, + ]) const handleCreateInvite = useCallback(async () => { if (!selectedSetId) return @@ -107,18 +178,40 @@ export function CredentialSets() { }) const inviteUrl = result.invitation?.inviteUrl if (inviteUrl) { - await navigator.clipboard.writeText(inviteUrl) - setCopiedLink(true) - setTimeout(() => setCopiedLink(false), 2000) + setGeneratedInviteUrl(inviteUrl) + try { + await navigator.clipboard.writeText(inviteUrl) + setCopiedLink(true) + setTimeout(() => setCopiedLink(false), 2000) + } catch { + // Clipboard failed, URL is shown in modal for manual copy + } } - setShowInviteModal(false) setInviteEmail('') - setSelectedSetId(null) } catch (error) { logger.error('Failed to create invitation', error) } }, [selectedSetId, inviteEmail, createInvitation]) + const handleCopyInviteUrl = useCallback(async () => { + if (!generatedInviteUrl) return + try { + await navigator.clipboard.writeText(generatedInviteUrl) + setCopiedLink(true) + setTimeout(() => setCopiedLink(false), 2000) + } catch { + // Fallback: select the input text + } + }, [generatedInviteUrl]) + + const handleCloseInviteModal = useCallback(() => { + setShowInviteModal(false) + setInviteEmail('') + setSelectedSetId(null) + setGeneratedInviteUrl(null) + setCopiedLink(false) + }, []) + if (membershipsLoading || invitationsLoading) { return } @@ -127,6 +220,207 @@ export function CredentialSets() { const hasNoContent = invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0 + // Detail view for a credential set + if (viewingSet) { + const activeMembers = members.filter((m) => m.status === 'active') + const pendingMembers = members.filter((m) => m.status === 'pending') + + return ( +
+
+ +
+ + {viewingSet.name} + + + {viewingSet.type === 'all' + ? 'All Integrations' + : `${getProviderName(viewingSet.providerId || '')} Only`} + +
+
+ + {membersLoading ? ( +
+ + +
+ ) : ( + <> + {activeMembers.length > 0 && ( +
+ + {activeMembers.map((member) => ( +
+
+ + + + + + +
+ + {member.userName || 'Unknown'} + + + {member.userEmail} + +
+
+ +
+ ))} +
+ )} + + {pendingMembers.length > 0 && ( +
+ + {pendingMembers.map((member) => ( +
+
+ + + + + + +
+ + {member.userName || 'Unknown'} + + + {member.userEmail} + +
+
+ Pending +
+ ))} +
+ )} + + {members.length === 0 && ( +
+

No members yet

+

+ Invite people to join this credential set +

+
+ )} + + )} + +
+ +
+ + + + Invite to Credential Set + +
+ {generatedInviteUrl ? ( + <> +
+ +
+ (e.target as HTMLInputElement).select()} + /> + +
+
+

+ Share this link with the person you want to invite. +

+ + ) : ( + <> +
+ + setInviteEmail(e.target.value)} + placeholder='Leave empty for a shareable link' + /> +
+

+ Generate an invite link to share. +

+ + )} +
+
+ + {generatedInviteUrl ? ( + + ) : ( + <> + + + + )} + +
+
+
+ ) + } + return (
{hasNoContent && !canManageCredentialSets && ( @@ -157,7 +451,7 @@ export function CredentialSets() {
@@ -218,7 +512,8 @@ export function CredentialSets() { ownedSets.map((set) => (
setViewingSet(set)} >
@@ -226,11 +521,15 @@ export function CredentialSets() { {set.memberCount} member{set.memberCount !== 1 ? 's' : ''} + {set.type === 'specific' && set.providerId && ( + <> · {getProviderName(set.providerId)} + )}
+
+ +
+ + +
+

+ {newSetType === 'all' + ? 'Members share all their connected credentials' + : 'Members share only credentials for a specific integration'} +

+
+ {newSetType === 'specific' && ( +
+ + p.value === newSetProviderId)?.label || + newSetProviderId + } + selectedValue={newSetProviderId} + onChange={(value) => setNewSetProviderId(value)} + placeholder='Select an integration' + filterOptions + /> +
+ )} - - + + +

+ Share this link with the person you want to invite. +

) : ( <> - - Copy Invite Link +
+ + setInviteEmail(e.target.value)} + placeholder='Leave empty for a shareable link' + /> +
+

+ Generate an invite link to share. +

)} - + + + + {generatedInviteUrl ? ( + + ) : ( + <> + + + + )} diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index d10fcc0283..a8f1bd3e36 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -252,6 +252,10 @@ async function executeWebhookJobInternal( }, } + if (payload.credentialId && typeof airtableInput === 'object') { + airtableInput.currentUser = payload.credentialId + } + const snapshot = new ExecutionSnapshot( metadata, workflow, @@ -511,7 +515,12 @@ async function executeWebhookJobInternal( }, } - const snapshot = new ExecutionSnapshot(metadata, workflow, input || {}, workflowVariables, []) + const triggerInput = input || {} + if (payload.credentialId && typeof triggerInput === 'object') { + triggerInput.currentUser = payload.credentialId + } + + const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, []) const executionResult = await executeWorkflowCore({ snapshot, diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 973db5b75e..a1d6df77d6 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -112,7 +112,6 @@ export const REFERENCE = { LOOP: 'loop', PARALLEL: 'parallel', VARIABLE: 'variable', - CREDENTIAL_SET: 'credentialSet', }, } as const @@ -120,7 +119,6 @@ export const SPECIAL_REFERENCE_PREFIXES = [ REFERENCE.PREFIX.LOOP, REFERENCE.PREFIX.PARALLEL, REFERENCE.PREFIX.VARIABLE, - REFERENCE.PREFIX.CREDENTIAL_SET, ] as const export const LOOP_REFERENCE = { @@ -182,6 +180,18 @@ export const MCP = { TOOL_PREFIX: 'mcp-', } as const +export const CREDENTIAL_SET = { + PREFIX: 'credentialSet:', +} as const + +export function isCredentialSetValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX) +} + +export function extractCredentialSetId(value: string): string { + return value.slice(CREDENTIAL_SET.PREFIX.length) +} + export const MEMORY = { DEFAULT_SLIDING_WINDOW_SIZE: 10, DEFAULT_SLIDING_WINDOW_TOKENS: 4000, diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index f4140b65f2..980708931b 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -4,7 +4,6 @@ import type { ExecutionState, LoopScope } from '@/executor/execution/state' import type { ExecutionContext } from '@/executor/types' import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation' import { BlockResolver } from '@/executor/variables/resolvers/block' -import { CredentialSetResolver } from '@/executor/variables/resolvers/credential-set' import { EnvResolver } from '@/executor/variables/resolvers/env' import { LoopResolver } from '@/executor/variables/resolvers/loop' import { ParallelResolver } from '@/executor/variables/resolvers/parallel' @@ -28,7 +27,6 @@ export class VariableResolver { new LoopResolver(workflow), new ParallelResolver(workflow), new WorkflowResolver(workflowVariables), - new CredentialSetResolver(), new EnvResolver(), this.blockResolver, ] diff --git a/apps/sim/executor/variables/resolvers/credential-set.ts b/apps/sim/executor/variables/resolvers/credential-set.ts deleted file mode 100644 index 00560b7749..0000000000 --- a/apps/sim/executor/variables/resolvers/credential-set.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createLogger } from '@sim/logger' -import { parseReferencePath, REFERENCE } from '@/executor/constants' -import type { ResolutionContext, Resolver } from '@/executor/variables/resolvers/reference' - -const logger = createLogger('CredentialSetResolver') - -export const CREDENTIAL_SET_USER_PREFIX = 'credentialSetUser:' - -export class CredentialSetResolver implements Resolver { - canResolve(reference: string): boolean { - const parts = parseReferencePath(reference) - if (parts.length < 1) return false - - const type = parts[0] - return type === REFERENCE.PREFIX.CREDENTIAL_SET - } - - resolve(reference: string, context: ResolutionContext): any { - const parts = parseReferencePath(reference) - if (parts.length < 2) { - logger.warn('Invalid credential set reference - missing property', { reference }) - return undefined - } - - const [_, property] = parts - - if (property === 'currentUser') { - const credentialAccountUserId = context.executionContext.metadata?.credentialAccountUserId - if (!credentialAccountUserId) { - logger.warn( - 'credentialSet.currentUser referenced but no credentialAccountUserId in execution context', - { reference } - ) - return undefined - } - return `${CREDENTIAL_SET_USER_PREFIX}${credentialAccountUserId}` - } - - logger.warn('Unknown credential set property', { property, reference }) - return undefined - } -} diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts index cc61d2bac3..9855fc6b4f 100644 --- a/apps/sim/hooks/queries/credential-sets.ts +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -1,10 +1,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { fetchJson } from '@/hooks/selectors/helpers' +export type CredentialSetType = 'all' | 'specific' + export interface CredentialSet { id: string name: string description: string | null + type: CredentialSetType + providerId: string | null createdBy: string createdAt: string updatedAt: string @@ -118,11 +122,19 @@ export function useAcceptCredentialSetInvitation() { }) } +export interface CreateCredentialSetData { + organizationId: string + name: string + description?: string + type?: CredentialSetType + providerId?: string +} + export function useCreateCredentialSet() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { organizationId: string; name: string; description?: string }) => { + mutationFn: async (data: CreateCredentialSetData) => { const response = await fetch('/api/credential-sets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -161,3 +173,57 @@ export function useCreateCredentialSetInvitation() { }, }) } + +export interface CredentialSetMember { + id: string + userId: string + status: string + joinedAt: string | null + createdAt: string + userName: string | null + userEmail: string | null + userImage: string | null + credentials: { providerId: string; accountId: string }[] +} + +interface MembersResponse { + members?: CredentialSetMember[] +} + +export function useCredentialSetMembers(credentialSetId?: string) { + return useQuery({ + queryKey: [...credentialSetKeys.detail(credentialSetId), 'members'], + queryFn: async () => { + const data = await fetchJson( + `/api/credential-sets/${credentialSetId}/members` + ) + return data.members ?? [] + }, + enabled: Boolean(credentialSetId), + staleTime: 30 * 1000, + }) +} + +export function useRemoveCredentialSetMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { credentialSetId: string; memberId: string }) => { + const response = await fetch( + `/api/credential-sets/${data.credentialSetId}/members?memberId=${data.memberId}`, + { method: 'DELETE' } + ) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to remove member') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'members'], + }) + queryClient.invalidateQueries({ queryKey: credentialSetKeys.all }) + }, + }) +} diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index a10382989c..e442658ee3 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -171,7 +171,22 @@ export function useWebhookManagement({ if (webhook.providerConfig) { const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId, webhook) - useSubBlockStore.getState().setValue(blockId, 'triggerConfig', webhook.providerConfig) + // Filter out runtime/system fields from providerConfig before storing as triggerConfig + // These fields are managed by the system and should not be included in change detection + const { + credentialId: _credId, + credentialSetId: _credSetId, + userId: _userId, + historyId: _historyId, + lastCheckedTimestamp: _lastChecked, + setupCompleted: _setupCompleted, + externalId: _externalId, + triggerId: _triggerId, + blockId: _blockId, + ...userConfigurableFields + } = webhook.providerConfig as Record + + useSubBlockStore.getState().setValue(blockId, 'triggerConfig', userConfigurableFields) if (effectiveTriggerId) { populateTriggerFieldsFromConfig(blockId, webhook.providerConfig, effectiveTriggerId) diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts index cc62c30afc..d237dae75f 100644 --- a/apps/sim/lib/webhooks/gmail-polling-service.ts +++ b/apps/sim/lib/webhooks/gmail-polling-service.ts @@ -141,17 +141,19 @@ export async function pollGmailWebhooks() { try { const metadata = webhookData.providerConfig as any + // Each webhook now has its own credentialId (credential sets are fanned out at save time) const credentialId: string | undefined = metadata?.credentialId const userId: string | undefined = metadata?.userId if (!credentialId && !userId) { - logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`) + logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`) await markWebhookFailed(webhookId) failureCount++ return } let accessToken: string | null = null + if (credentialId) { const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (rows.length === 0) { @@ -165,13 +167,12 @@ export async function pollGmailWebhooks() { const ownerUserId = rows[0].userId accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) } else if (userId) { + // Legacy fallback for webhooks without credentialId accessToken = await getOAuthToken(userId, 'google-email') } if (!accessToken) { - logger.error( - `[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)` - ) + logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`) await markWebhookFailed(webhookId) failureCount++ return diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index d1ef7b1c6a..cc4922080c 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -1,10 +1,12 @@ import { db, webhook, workflow } from '@sim/db' +import { credentialSet, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils' +import { isProd, isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { preprocessExecution } from '@/lib/execution/preprocessing' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' import { @@ -13,7 +15,6 @@ import { validateMicrosoftTeamsSignature, verifyProviderWebhook, } from '@/lib/webhooks/utils.server' -import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils' import { executeWebhookJob } from '@/background/webhook-execution' import { REFERENCE } from '@/executor/constants' import { createEnvVarPattern } from '@/executor/utils/reference-validation' @@ -41,6 +42,48 @@ function getExternalUrl(request: NextRequest): string { return request.url } +async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ + valid: boolean + error?: string +}> { + if (!isProd) { + return { valid: true } + } + + const [set] = await db + .select({ organizationId: credentialSet.organizationId }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) { + return { valid: false, error: 'Credential set not found' } + } + + const [orgSub] = await db + .select() + .from(subscription) + .where(and(eq(subscription.referenceId, set.organizationId), eq(subscription.status, 'active'))) + .limit(1) + + if (!orgSub) { + return { + valid: false, + error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.', + } + } + + const hasTeamPlan = checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub) + if (!hasTeamPlan) { + return { + valid: false, + error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.', + } + } + + return { valid: true } +} + export async function parseWebhookBody( request: NextRequest, requestId: string @@ -194,6 +237,37 @@ export async function findWebhookAndWorkflow( return null } +/** + * Find ALL webhooks matching a path. + * Used for credential sets where multiple webhooks share the same path. + */ +export async function findAllWebhooksForPath( + options: WebhookProcessorOptions +): Promise> { + if (!options.path) { + return [] + } + + const results = await db + .select({ + webhook: webhook, + workflow: workflow, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.path, options.path), eq(webhook.isActive, true))) + + if (results.length === 0) { + logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`) + } else if (results.length > 1) { + logger.info( + `[${options.requestId}] Found ${results.length} webhooks for path: ${options.path} (credential set fan-out)` + ) + } + + return results +} + /** * Resolve {{VARIABLE}} references in a string value * @param value - String that may contain {{VARIABLE}} references @@ -748,12 +822,24 @@ export async function queueWebhookExecution( } } - // Extract credentialId or credentialSetId from webhook config + // Extract credentialId from webhook config + // Note: Each webhook now has its own credentialId (credential sets are fanned out at save time) const providerConfig = (foundWebhook.providerConfig as Record) || {} const credentialId = providerConfig.credentialId as string | undefined const credentialSetId = providerConfig.credentialSetId as string | undefined - const basePayload = { + // Verify billing for credential sets + if (credentialSetId) { + const billingCheck = await verifyCredentialSetBilling(credentialSetId) + if (!billingCheck.valid) { + logger.warn( + `[${options.requestId}] Credential set billing check failed: ${billingCheck.error}` + ) + return NextResponse.json({ error: billingCheck.error }, { status: 403 }) + } + } + + const payload = { webhookId: foundWebhook.id, workflowId: foundWorkflow.id, userId: foundWorkflow.userId, @@ -764,72 +850,25 @@ export async function queueWebhookExecution( blockId: foundWebhook.blockId, testMode: options.testMode, executionTarget: options.executionTarget, + ...(credentialId ? { credentialId } : {}), } - if (credentialSetId) { - const credentials = await getCredentialsForCredentialSet( - credentialSetId, - foundWebhook.provider - ) - - if (credentials.length === 0) { - logger.warn( - `[${options.requestId}] No valid credentials found for credential set ${credentialSetId}, provider ${foundWebhook.provider}` - ) - return NextResponse.json( - { error: 'No valid credentials in credential set' }, - { status: 400 } - ) - } - + if (isTriggerDevEnabled) { + const handle = await tasks.trigger('webhook-execution', payload) logger.info( - `[${options.requestId}] Fan-out: Queuing ${credentials.length} executions for credential set ${credentialSetId}` + `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ + handle.id + } for ${foundWebhook.provider} webhook` ) - - for (const cred of credentials) { - const fanOutPayload = { - ...basePayload, - credentialId: cred.credentialId, - credentialAccountUserId: cred.userId, - } - - if (isTriggerDevEnabled) { - const handle = await tasks.trigger('webhook-execution', fanOutPayload) - logger.info( - `[${options.requestId}] Queued fan-out execution ${handle.id} for user ${cred.userId}` - ) - } else { - void executeWebhookJob(fanOutPayload).catch((error) => { - logger.error( - `[${options.requestId}] Direct fan-out execution failed for user ${cred.userId}`, - error - ) - }) - } - } } else { - const payload = { - ...basePayload, - ...(credentialId ? { credentialId } : {}), - } - - if (isTriggerDevEnabled) { - const handle = await tasks.trigger('webhook-execution', payload) - logger.info( - `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ - handle.id - } for ${foundWebhook.provider} webhook` - ) - } else { - void executeWebhookJob(payload).catch((error) => { - logger.error(`[${options.requestId}] Direct webhook execution failed`, error) - }) - logger.info( - `[${options.requestId}] Queued direct ${ - options.testMode ? 'TEST ' : '' - }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)` - ) - } + void executeWebhookJob(payload).catch((error) => { + logger.error(`[${options.requestId}] Direct webhook execution failed`, error) + }) + logger.info( + `[${options.requestId}] Queued direct ${ + options.testMode ? 'TEST ' : '' + }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)` + ) } if (foundWebhook.provider === 'microsoft-teams') { diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 543cb4fc59..69fcf1eba7 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -2397,7 +2397,307 @@ export interface AirtableChange { } /** - * Configure Gmail polling for a webhook + * Result of syncing webhooks for a credential set + */ +export interface CredentialSetWebhookSyncResult { + webhooks: Array<{ + id: string + credentialId: string + isNew: boolean + }> + created: number + updated: number + deleted: number +} + +/** + * Sync webhooks for a credential set. + * + * For credential sets, we create one webhook per credential in the set. + * Each webhook has its own state and credentialId. + * + * Path strategy: + * - Polling triggers (gmail, outlook): unique paths per credential (for independent polling) + * - External triggers (slack, etc.): shared path (external service sends to one URL) + * + * This function: + * 1. Gets all credentials in the credential set + * 2. Gets existing webhooks for this workflow+block with this credentialSetId + * 3. Creates webhooks for new credentials + * 4. Updates config for existing webhooks (preserving state) + * 5. Deletes webhooks for credentials no longer in the set + */ +export async function syncWebhooksForCredentialSet(params: { + workflowId: string + blockId: string + provider: string + basePath: string + credentialSetId: string + oauthProviderId: string + providerConfig: Record + requestId: string +}): Promise { + const { + workflowId, + blockId, + provider, + basePath, + credentialSetId, + oauthProviderId, + providerConfig, + requestId, + } = params + + const syncLogger = createLogger('CredentialSetWebhookSync') + syncLogger.info( + `[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}` + ) + + const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils') + const { nanoid } = await import('nanoid') + + // Polling providers get unique paths per credential (for independent state) + // External webhook providers share the same path (external service sends to one URL) + const pollingProviders = ['gmail', 'outlook', 'rss', 'imap'] + const useUniquePaths = pollingProviders.includes(provider) + + // Get all credentials in the set + const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId) + + if (credentials.length === 0) { + syncLogger.warn( + `[${requestId}] No credentials found in credential set ${credentialSetId} for provider ${oauthProviderId}` + ) + return { webhooks: [], created: 0, updated: 0, deleted: 0 } + } + + syncLogger.info( + `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}` + ) + + // Get existing webhooks for this workflow+block that belong to this credential set + const existingWebhooks = await db + .select() + .from(webhook) + .where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId))) + + // Filter to only webhooks belonging to this credential set + const credentialSetWebhooks = existingWebhooks.filter((wh) => { + const config = wh.providerConfig as Record + return config?.credentialSetId === credentialSetId + }) + + syncLogger.info( + `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set` + ) + + // Build maps for efficient lookup + const existingByCredentialId = new Map() + for (const wh of credentialSetWebhooks) { + const config = wh.providerConfig as Record + if (config?.credentialId) { + existingByCredentialId.set(config.credentialId, wh) + } + } + + const credentialIdsInSet = new Set(credentials.map((c) => c.credentialId)) + + const result: CredentialSetWebhookSyncResult = { + webhooks: [], + created: 0, + updated: 0, + deleted: 0, + } + + // Process each credential in the set + for (const cred of credentials) { + const existingWebhook = existingByCredentialId.get(cred.credentialId) + + if (existingWebhook) { + // Update existing webhook - preserve state fields + const existingConfig = existingWebhook.providerConfig as Record + + const updatedConfig = { + ...providerConfig, + credentialId: cred.credentialId, + credentialSetId: credentialSetId, + // Preserve state fields from existing config + historyId: existingConfig.historyId, + lastCheckedTimestamp: existingConfig.lastCheckedTimestamp, + setupCompleted: existingConfig.setupCompleted, + externalId: existingConfig.externalId, + userId: cred.userId, + } + + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, + isActive: true, + updatedAt: new Date(), + }) + .where(eq(webhook.id, existingWebhook.id)) + + result.webhooks.push({ + id: existingWebhook.id, + credentialId: cred.credentialId, + isNew: false, + }) + result.updated++ + + syncLogger.debug( + `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}` + ) + } else { + // Create new webhook for this credential + const webhookId = nanoid() + const webhookPath = useUniquePaths ? `${basePath}-${cred.credentialId.slice(0, 8)}` : basePath + + const newConfig = { + ...providerConfig, + credentialId: cred.credentialId, + credentialSetId: credentialSetId, + userId: cred.userId, + } + + await db.insert(webhook).values({ + id: webhookId, + workflowId, + blockId, + path: webhookPath, + provider, + providerConfig: newConfig, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + + result.webhooks.push({ + id: webhookId, + credentialId: cred.credentialId, + isNew: true, + }) + result.created++ + + syncLogger.debug( + `[${requestId}] Created webhook ${webhookId} for credential ${cred.credentialId}` + ) + } + } + + // Delete webhooks for credentials no longer in the set + for (const [credentialId, existingWebhook] of existingByCredentialId) { + if (!credentialIdsInSet.has(credentialId)) { + await db.delete(webhook).where(eq(webhook.id, existingWebhook.id)) + result.deleted++ + + syncLogger.debug( + `[${requestId}] Deleted webhook ${existingWebhook.id} for removed credential ${credentialId}` + ) + } + } + + syncLogger.info( + `[${requestId}] Credential set webhook sync complete: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted` + ) + + return result +} + +/** + * Sync all webhooks that use a specific credential set. + * Called when credential set membership changes (member added/removed). + * + * This finds all workflows with webhooks using this credential set and resyncs them. + */ +export async function syncAllWebhooksForCredentialSet( + credentialSetId: string, + requestId: string +): Promise<{ workflowsUpdated: number; totalCreated: number; totalDeleted: number }> { + const syncLogger = createLogger('CredentialSetMembershipSync') + syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`) + + const { getProviderIdFromServiceId } = await import('@/lib/oauth') + + // Find all webhooks that use this credential set + const allWebhooks = await db.select().from(webhook) + + // Filter to webhooks with this credentialSetId in their providerConfig + const webhooksForSet = allWebhooks.filter((wh) => { + const config = wh.providerConfig as Record + return config?.credentialSetId === credentialSetId + }) + + if (webhooksForSet.length === 0) { + syncLogger.info(`[${requestId}] No webhooks found using credential set ${credentialSetId}`) + return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 } + } + + // Group webhooks by workflow+block to find unique triggers + const triggerGroups = new Map() + for (const wh of webhooksForSet) { + const key = `${wh.workflowId}:${wh.blockId}` + // Keep the first webhook as representative (they all have same config) + if (!triggerGroups.has(key)) { + triggerGroups.set(key, wh) + } + } + + syncLogger.info( + `[${requestId}] Found ${triggerGroups.size} triggers using credential set ${credentialSetId}` + ) + + let workflowsUpdated = 0 + let totalCreated = 0 + let totalDeleted = 0 + + for (const [key, representativeWebhook] of triggerGroups) { + if (!representativeWebhook.provider) { + syncLogger.warn(`[${requestId}] Skipping webhook without provider: ${key}`) + continue + } + + const config = representativeWebhook.providerConfig as Record + const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider) + + const { credentialId: _cId, userId: _uId, ...baseConfig } = config + const pathParts = representativeWebhook.path.split('-') + const basePath = `${pathParts[0]}-${pathParts[1]}` + + try { + const result = await syncWebhooksForCredentialSet({ + workflowId: representativeWebhook.workflowId, + blockId: representativeWebhook.blockId || '', + provider: representativeWebhook.provider, + basePath, + credentialSetId, + oauthProviderId, + providerConfig: baseConfig, + requestId, + }) + + workflowsUpdated++ + totalCreated += result.created + totalDeleted += result.deleted + + syncLogger.debug( + `[${requestId}] Synced webhooks for ${key}: ${result.created} created, ${result.deleted} deleted` + ) + } catch (error) { + syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error) + } + } + + syncLogger.info( + `[${requestId}] Credential set membership sync complete: ${workflowsUpdated} workflows updated, ${totalCreated} webhooks created, ${totalDeleted} webhooks deleted` + ) + + return { workflowsUpdated, totalCreated, totalDeleted } +} + +/** + * Configure Gmail polling for a webhook. + * Each webhook has its own credentialId (credential sets are fanned out at save time). */ export async function configureGmailPolling(webhookData: any, requestId: string): Promise { const logger = createLogger('GmailWebhookSetup') @@ -2406,46 +2706,30 @@ export async function configureGmailPolling(webhookData: any, requestId: string) try { const providerConfig = (webhookData.providerConfig as Record) || {} const credentialId: string | undefined = providerConfig.credentialId - const credentialSetId: string | undefined = providerConfig.credentialSetId - if (!credentialId && !credentialSetId) { + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } + + // Verify credential exists and get userId + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { logger.error( - `[${requestId}] Missing credentialId or credentialSetId for Gmail webhook ${webhookData.id}` + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` ) return false } - let effectiveUserId: string | undefined + const effectiveUserId = rows[0].userId - if (credentialSetId) { - const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils') - const credentials = await getCredentialsForCredentialSet(credentialSetId, 'google-email') - if (credentials.length === 0) { - logger.error( - `[${requestId}] No Gmail credentials found in credential set ${credentialSetId}` - ) - return false - } - logger.info( - `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Gmail credentials` + // Verify token can be refreshed + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` ) - } else { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - - effectiveUserId = rows[0].userId - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } + return false } const maxEmailsPerPoll = @@ -2465,16 +2749,15 @@ export async function configureGmailPolling(webhookData: any, requestId: string) .set({ providerConfig: { ...providerConfig, - ...(effectiveUserId ? { userId: effectiveUserId } : {}), - ...(credentialId ? { credentialId } : {}), - ...(credentialSetId ? { credentialSetId } : {}), + userId: effectiveUserId, + credentialId, maxEmailsPerPoll, pollingInterval, markAsRead: providerConfig.markAsRead || false, includeRawEmail: providerConfig.includeRawEmail || false, labelIds: providerConfig.labelIds || ['INBOX'], labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: now.toISOString(), + lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), setupCompleted: true, }, updatedAt: now, @@ -2496,7 +2779,8 @@ export async function configureGmailPolling(webhookData: any, requestId: string) } /** - * Configure Outlook polling for a webhook + * Configure Outlook polling for a webhook. + * Each webhook has its own credentialId (credential sets are fanned out at save time). */ export async function configureOutlookPolling( webhookData: any, @@ -2508,73 +2792,54 @@ export async function configureOutlookPolling( try { const providerConfig = (webhookData.providerConfig as Record) || {} const credentialId: string | undefined = providerConfig.credentialId - const credentialSetId: string | undefined = providerConfig.credentialSetId - if (!credentialId && !credentialSetId) { + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } + + // Verify credential exists and get userId + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { logger.error( - `[${requestId}] Missing credentialId or credentialSetId for Outlook webhook ${webhookData.id}` + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` ) return false } - let effectiveUserId: string | undefined + const effectiveUserId = rows[0].userId - if (credentialSetId) { - const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils') - const credentials = await getCredentialsForCredentialSet(credentialSetId, 'outlook') - if (credentials.length === 0) { - logger.error( - `[${requestId}] No Outlook credentials found in credential set ${credentialSetId}` - ) - return false - } - logger.info( - `[${requestId}] Credential set ${credentialSetId} has ${credentials.length} Outlook credentials` + // Verify token can be refreshed + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` ) - } else { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - - effectiveUserId = rows[0].userId - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } + return false } - const providerCfg = (webhookData.providerConfig as Record) || {} - const now = new Date() await db .update(webhook) .set({ providerConfig: { - ...providerCfg, - ...(effectiveUserId ? { userId: effectiveUserId } : {}), - ...(credentialId ? { credentialId } : {}), - ...(credentialSetId ? { credentialSetId } : {}), + ...providerConfig, + userId: effectiveUserId, + credentialId, maxEmailsPerPoll: - typeof providerCfg.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25 - : providerCfg.maxEmailsPerPoll || 25, + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : providerConfig.maxEmailsPerPoll || 25, pollingInterval: - typeof providerCfg.pollingInterval === 'string' - ? Number.parseInt(providerCfg.pollingInterval, 10) || 5 - : providerCfg.pollingInterval || 5, - markAsRead: providerCfg.markAsRead || false, - includeRawEmail: providerCfg.includeRawEmail || false, - folderIds: providerCfg.folderIds || ['inbox'], - folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: now.toISOString(), + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : providerConfig.pollingInterval || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), setupCompleted: true, }, updatedAt: now, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index af918c8b4e..5d91854c74 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -5,7 +5,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { parseMcpToolId } from '@/lib/mcp/utils' import { isCustomTool, isMcpTool } from '@/executor/constants' import type { ExecutionContext } from '@/executor/types' -import { CREDENTIAL_SET_USER_PREFIX } from '@/executor/variables/resolvers/credential-set' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types' @@ -254,25 +253,8 @@ export async function executeTool( try { const baseUrl = getBaseUrl() - // Prepare the token payload - const tokenPayload: OAuthTokenPayload = {} - const credentialValue = contextParams.credential as string - - if (credentialValue.startsWith(CREDENTIAL_SET_USER_PREFIX)) { - const credentialAccountUserId = credentialValue.slice(CREDENTIAL_SET_USER_PREFIX.length) - const providerId = tool?.oauth?.provider - if (!providerId) { - throw new Error( - `Cannot resolve credential set user without providerId for tool ${toolId}` - ) - } - tokenPayload.credentialAccountUserId = credentialAccountUserId - tokenPayload.providerId = providerId - logger.info( - `[${requestId}] Using credential set user: ${credentialAccountUserId}, provider: ${providerId}` - ) - } else { - tokenPayload.credentialId = credentialValue + const tokenPayload: OAuthTokenPayload = { + credentialId: contextParams.credential as string, } // Add workflowId if it exists in params, context, or executionContext diff --git a/apps/sim/triggers/airtable/webhook.ts b/apps/sim/triggers/airtable/webhook.ts index 45c8f6cf3a..8dca58c329 100644 --- a/apps/sim/triggers/airtable/webhook.ts +++ b/apps/sim/triggers/airtable/webhook.ts @@ -20,6 +20,7 @@ export const airtableWebhookTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'baseId', diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index d731246248..261634f0cf 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -13,6 +13,7 @@ export const SYSTEM_SUBBLOCK_IDS: string[] = [ 'setupScript', // Setup script code (e.g., Apps Script) 'triggerId', // Stored trigger ID 'selectedTriggerId', // Selected trigger from dropdown (multi-trigger blocks) + 'triggerConfig', // Aggregated trigger config (derived from individual fields) ] /** diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index cfda07a2d9..48f0eb5e56 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -1,10 +1,28 @@ import { createLogger } from '@sim/logger' import { GmailIcon } from '@/components/icons' +import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('GmailPollingTrigger') +// Gmail system labels that exist for all accounts (used as defaults for credential sets) +const GMAIL_SYSTEM_LABELS = [ + { id: 'INBOX', label: 'Inbox' }, + { id: 'SENT', label: 'Sent' }, + { id: 'DRAFT', label: 'Drafts' }, + { id: 'SPAM', label: 'Spam' }, + { id: 'TRASH', label: 'Trash' }, + { id: 'STARRED', label: 'Starred' }, + { id: 'IMPORTANT', label: 'Important' }, + { id: 'UNREAD', label: 'Unread' }, + { id: 'CATEGORY_PERSONAL', label: 'Category: Personal' }, + { id: 'CATEGORY_SOCIAL', label: 'Category: Social' }, + { id: 'CATEGORY_PROMOTIONS', label: 'Category: Promotions' }, + { id: 'CATEGORY_UPDATES', label: 'Category: Updates' }, + { id: 'CATEGORY_FORUMS', label: 'Category: Forums' }, +] + export const gmailPollingTrigger: TriggerConfig = { id: 'gmail_poller', name: 'Gmail Email Trigger', @@ -23,6 +41,7 @@ export const gmailPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'labelIds', @@ -41,6 +60,10 @@ export const gmailPollingTrigger: TriggerConfig = { // Return a sentinel to prevent infinite retry loops when credential is missing throw new Error('No Gmail credential selected') } + // Return default system labels for credential sets (can't fetch user-specific labels for a pool) + if (isCredentialSetValue(credentialId)) { + return GMAIL_SYSTEM_LABELS + } try { const response = await fetch(`/api/tools/gmail/labels?credentialId=${credentialId}`) if (!response.ok) { diff --git a/apps/sim/triggers/jira/utils.ts b/apps/sim/triggers/jira/utils.ts index 3a3386e9cc..5262f5c6d6 100644 --- a/apps/sim/triggers/jira/utils.ts +++ b/apps/sim/triggers/jira/utils.ts @@ -44,6 +44,7 @@ export const jiraWebhookSubBlocks: SubBlockConfig[] = [ placeholder: 'Select Jira account', required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'webhookUrlDisplay', diff --git a/apps/sim/triggers/microsoftteams/chat_webhook.ts b/apps/sim/triggers/microsoftteams/chat_webhook.ts index 340b2a567b..543a493efc 100644 --- a/apps/sim/triggers/microsoftteams/chat_webhook.ts +++ b/apps/sim/triggers/microsoftteams/chat_webhook.ts @@ -41,6 +41,7 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = { ], required: true, mode: 'trigger', + supportsCredentialSets: true, condition: { field: 'selectedTriggerId', value: 'microsoftteams_chat_subscription', diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index 9beeba252c..ef2c8bd04d 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -1,10 +1,22 @@ import { createLogger } from '@sim/logger' import { OutlookIcon } from '@/components/icons' +import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('OutlookPollingTrigger') +// Outlook well-known folders that exist for all accounts (used as defaults for credential sets) +const OUTLOOK_SYSTEM_FOLDERS = [ + { id: 'inbox', label: 'Inbox' }, + { id: 'drafts', label: 'Drafts' }, + { id: 'sentitems', label: 'Sent Items' }, + { id: 'deleteditems', label: 'Deleted Items' }, + { id: 'junkemail', label: 'Junk Email' }, + { id: 'archive', label: 'Archive' }, + { id: 'outbox', label: 'Outbox' }, +] + export const outlookPollingTrigger: TriggerConfig = { id: 'outlook_poller', name: 'Outlook Email Trigger', @@ -23,6 +35,7 @@ export const outlookPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'folderIds', @@ -40,6 +53,10 @@ export const outlookPollingTrigger: TriggerConfig = { if (!credentialId) { throw new Error('No Outlook credential selected') } + // Return default system folders for credential sets (can't fetch user-specific folders for a pool) + if (isCredentialSetValue(credentialId)) { + return OUTLOOK_SYSTEM_FOLDERS + } try { const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`) if (!response.ok) { diff --git a/apps/sim/triggers/webflow/collection_item_changed.ts b/apps/sim/triggers/webflow/collection_item_changed.ts index 68657d588b..1802bfdf95 100644 --- a/apps/sim/triggers/webflow/collection_item_changed.ts +++ b/apps/sim/triggers/webflow/collection_item_changed.ts @@ -20,6 +20,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_changed', diff --git a/apps/sim/triggers/webflow/collection_item_created.ts b/apps/sim/triggers/webflow/collection_item_created.ts index a4eaa831a8..46a4da9c69 100644 --- a/apps/sim/triggers/webflow/collection_item_created.ts +++ b/apps/sim/triggers/webflow/collection_item_created.ts @@ -33,6 +33,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_created', diff --git a/apps/sim/triggers/webflow/collection_item_deleted.ts b/apps/sim/triggers/webflow/collection_item_deleted.ts index 6b50f63986..535030e030 100644 --- a/apps/sim/triggers/webflow/collection_item_deleted.ts +++ b/apps/sim/triggers/webflow/collection_item_deleted.ts @@ -20,6 +20,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_deleted', diff --git a/apps/sim/triggers/webflow/form_submission.ts b/apps/sim/triggers/webflow/form_submission.ts index e0a0725e8c..fc84078ff9 100644 --- a/apps/sim/triggers/webflow/form_submission.ts +++ b/apps/sim/triggers/webflow/form_submission.ts @@ -20,6 +20,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'siteId', diff --git a/packages/db/schema.ts b/packages/db/schema.ts index ac01cacddf..68173a7957 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1778,6 +1778,8 @@ export const usageLog = pgTable( }) ) +export const credentialSetTypeEnum = pgEnum('credential_set_type', ['all', 'specific']) + export const credentialSet = pgTable( 'credential_set', { @@ -1787,6 +1789,8 @@ export const credentialSet = pgTable( .references(() => organization.id, { onDelete: 'cascade' }), name: text('name').notNull(), description: text('description'), + type: credentialSetTypeEnum('type').notNull().default('all'), + providerId: text('provider_id'), createdBy: text('created_by') .notNull() .references(() => user.id, { onDelete: 'cascade' }), @@ -1800,14 +1804,13 @@ export const credentialSet = pgTable( table.organizationId, table.name ), + typeIdx: index('credential_set_type_idx').on(table.type), }) ) export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [ 'active', 'pending', - 'revoked', - 'credentials_missing', ]) export const credentialSetMember = pgTable( From 1db3e33cf61b22555b49e728505a0b00e2d518dc Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Jan 2026 17:05:26 -0800 Subject: [PATCH 03/21] return data to render credential set in block preview --- apps/sim/hooks/queries/oauth-credentials.ts | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts index f692321653..89b9913eb6 100644 --- a/apps/sim/hooks/queries/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth-credentials.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' import type { Credential } from '@/lib/oauth' +import { CREDENTIAL_SET } from '@/executor/constants' +import { useCredentialSetMemberships } from '@/hooks/queries/credential-sets' import { fetchJson } from '@/hooks/selectors/helpers' interface CredentialListResponse { @@ -61,14 +63,25 @@ export function useOAuthCredentialDetail( } export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) { + // Check if this is a credential set value + const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false + const credentialSetId = isCredentialSet + ? credentialId?.slice(CREDENTIAL_SET.PREFIX.length) + : undefined + + // Fetch credential set memberships if this is a credential set + const { data: memberships = [], isFetching: membershipsLoading } = useCredentialSetMemberships() + const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials( providerId, - Boolean(providerId) + Boolean(providerId) && !isCredentialSet ) const selectedCredential = credentials.find((cred) => cred.id === credentialId) - const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId) + const shouldFetchDetail = Boolean( + credentialId && !selectedCredential && providerId && workflowId && !isCredentialSet + ) const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail( shouldFetchDetail ? credentialId : undefined, @@ -78,11 +91,19 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo const hasForeignMeta = foreignCredentials.length > 0 - const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null) + // For credential sets, find the matching membership and use its name + const credentialSetName = credentialSetId + ? memberships.find((m) => m.credentialSetId === credentialSetId)?.credentialSetName + : undefined + + const displayName = + credentialSetName ?? + selectedCredential?.name ?? + (hasForeignMeta ? 'Saved by collaborator' : null) return { displayName, - isLoading: credentialsLoading || foreignLoading, + isLoading: credentialsLoading || foreignLoading || (isCredentialSet && membershipsLoading), hasForeignMeta, } } From 2d5cc9b4ad3ca1161ef9d482e491ae0a983453b3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Jan 2026 20:32:05 -0800 Subject: [PATCH 04/21] progress --- .../app/api/auth/oauth/disconnect/route.ts | 47 +- .../api/credential-sets/[id]/invite/route.ts | 54 +- .../api/credential-sets/[id]/members/route.ts | 24 +- .../credential-sets/invite/[token]/route.ts | 86 ++- .../api/credential-sets/memberships/route.ts | 79 ++- apps/sim/app/api/webhooks/route.ts | 8 + .../sim/app/api/workflows/[id]/state/route.ts | 2 + .../credential-sets/credential-sets.tsx | 507 +++++++++--------- .../settings-modal/settings-modal.tsx | 10 +- apps/sim/background/webhook-execution.ts | 7 - apps/sim/blocks/blocks/webhook.ts | 2 - .../components/emails/invitations/index.ts | 1 + .../polling-group-invitation-email.tsx | 55 ++ apps/sim/components/emails/render.ts | 19 + apps/sim/components/emails/subjects.ts | 3 + apps/sim/hooks/queries/credential-sets.ts | 21 + apps/sim/lib/auth/auth.ts | 92 ++++ apps/sim/lib/webhooks/utils.server.ts | 40 +- apps/sim/lib/workflows/persistence/utils.ts | 1 + apps/sim/triggers/airtable/webhook.ts | 1 - apps/sim/triggers/jira/utils.ts | 1 - .../triggers/microsoftteams/chat_webhook.ts | 1 - .../webflow/collection_item_changed.ts | 1 - .../webflow/collection_item_created.ts | 1 - .../webflow/collection_item_deleted.ts | 1 - apps/sim/triggers/webflow/form_submission.ts | 1 - packages/db/schema.ts | 6 + 27 files changed, 730 insertions(+), 341 deletions(-) create mode 100644 apps/sim/components/emails/invitations/polling-group-invitation-email.tsx diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 5050e86172..2cbb7ad60b 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' -import { account } from '@sim/db/schema' +import { account, credentialSet, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, or } 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 { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' export const dynamic = 'force-dynamic' @@ -74,6 +75,50 @@ export async function POST(request: NextRequest) { ) } + // Sync webhooks for all credential sets the user is a member of + // This removes webhooks that were using the disconnected credential + const userMemberships = await db + .select({ + id: credentialSetMember.id, + credentialSetId: credentialSetMember.credentialSetId, + providerId: credentialSet.providerId, + type: credentialSet.type, + }) + .from(credentialSetMember) + .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) + .where( + and( + eq(credentialSetMember.userId, session.user.id), + eq(credentialSetMember.status, 'active') + ) + ) + + for (const membership of userMemberships) { + // Only sync if the credential set matches this provider or is 'all' type + const matchesProvider = + membership.type === 'all' || + membership.providerId === provider || + membership.providerId === providerId || + (membership.providerId && provider.startsWith(membership.providerId)) + + if (matchesProvider) { + try { + await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) + logger.info(`[${requestId}] Synced webhooks after credential disconnect`, { + credentialSetId: membership.credentialSetId, + provider, + }) + } catch (error) { + // Log but don't fail the disconnect - credential is already removed + logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, { + credentialSetId: membership.credentialSetId, + provider, + error, + }) + } + } + } + return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index 147cf49363..7c911b4075 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -1,10 +1,13 @@ import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, member } from '@sim/db/schema' +import { credentialSet, credentialSetInvitation, member, organization, 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 { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' import { getSession } from '@/lib/auth' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' const logger = createLogger('CredentialSetInvite') @@ -18,6 +21,7 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin id: credentialSet.id, organizationId: credentialSet.organizationId, name: credentialSet.name, + providerId: credentialSet.providerId, }) .from(credentialSet) .where(eq(credentialSet.id, credentialSetId)) @@ -98,12 +102,58 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: await db.insert(credentialSetInvitation).values(invitation) - const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/credential-account/${token}` + const inviteUrl = `${getBaseUrl()}/credential-account/${token}` + + // Send email if email address was provided + if (email) { + try { + // Get inviter name + const [inviter] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + // Get organization name + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, result.set.organizationId)) + .limit(1) + + const provider = (result.set.providerId as 'gmail' | 'outlook') || 'gmail' + const emailHtml = await renderPollingGroupInvitationEmail({ + inviterName: inviter?.name || 'A team member', + organizationName: org?.name || 'your organization', + pollingGroupName: result.set.name, + provider, + inviteLink: inviteUrl, + }) + + const emailResult = await sendEmail({ + to: email, + subject: getEmailSubject('polling-group-invitation'), + html: emailHtml, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.warn('Failed to send invitation email', { + email, + error: emailResult.message, + }) + } + } catch (emailError) { + logger.error('Error sending invitation email', emailError) + // Don't fail the invitation creation if email fails + } + } logger.info('Created credential set invitation', { credentialSetId: id, invitationId: invitation.id, userId: session.user.id, + emailSent: !!email, }) return NextResponse.json({ diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index 7f92d46fe1..bc62e29873 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -152,24 +152,24 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i return NextResponse.json({ error: 'Member not found' }, { status: 404 }) } - await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) + const requestId = crypto.randomUUID().slice(0, 8) - logger.info('Removed member from credential set', { - credentialSetId: id, - memberId, - userId: session.user.id, - }) + // Use transaction to ensure member deletion + webhook sync are atomic + await db.transaction(async (tx) => { + await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) - try { - const requestId = crypto.randomUUID().slice(0, 8) - const syncResult = await syncAllWebhooksForCredentialSet(id, requestId) + const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx) logger.info('Synced webhooks after member removed', { credentialSetId: id, ...syncResult, }) - } catch (syncError) { - logger.error('Error syncing webhooks after member removed', syncError) - } + }) + + logger.info('Removed member from credential set', { + credentialSetId: id, + memberId, + userId: session.user.id, + }) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index a0f8b0e664..de9ad6df40 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -91,10 +91,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) } - if (invitation.email && invitation.email !== session.user.email) { - return NextResponse.json({ error: 'Email does not match invitation' }, { status: 400 }) - } - const existingMember = await db .select() .from(credentialSetMember) @@ -114,64 +110,66 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok } const now = new Date() - await db.insert(credentialSetMember).values({ - id: crypto.randomUUID(), - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - status: 'active', - joinedAt: now, - invitedBy: invitation.invitedBy, - createdAt: now, - updatedAt: now, - }) + const requestId = crypto.randomUUID().slice(0, 8) - await db - .update(credentialSetInvitation) - .set({ - status: 'accepted', - acceptedAt: now, - acceptedByUserId: session.user.id, + // Use transaction to ensure membership + invitation update + webhook sync are atomic + await db.transaction(async (tx) => { + await tx.insert(credentialSetMember).values({ + id: crypto.randomUUID(), + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + status: 'active', + joinedAt: now, + invitedBy: invitation.invitedBy, + createdAt: now, + updatedAt: now, }) - .where(eq(credentialSetInvitation.id, invitation.id)) - // Clean up all other pending invitations for the same credential set and email - // This prevents duplicate invites from showing up after accepting one - if (invitation.email) { - await db + await tx .update(credentialSetInvitation) .set({ status: 'accepted', acceptedAt: now, acceptedByUserId: session.user.id, }) - .where( - and( - eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), - eq(credentialSetInvitation.email, invitation.email), - eq(credentialSetInvitation.status, 'pending') - ) - ) - } + .where(eq(credentialSetInvitation.id, invitation.id)) - logger.info('Accepted credential set invitation', { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - }) + // Clean up all other pending invitations for the same credential set and email + // This prevents duplicate invites from showing up after accepting one + if (invitation.email) { + await tx + .update(credentialSetInvitation) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByUserId: session.user.id, + }) + .where( + and( + eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), + eq(credentialSetInvitation.email, invitation.email), + eq(credentialSetInvitation.status, 'pending') + ) + ) + } - try { - const requestId = crypto.randomUUID().slice(0, 8) + // Sync webhooks within the transaction const syncResult = await syncAllWebhooksForCredentialSet( invitation.credentialSetId, - requestId + requestId, + tx ) logger.info('Synced webhooks after member joined', { credentialSetId: invitation.credentialSetId, ...syncResult, }) - } catch (syncError) { - logger.error('Error syncing webhooks after member joined', syncError) - } + }) + + logger.info('Accepted credential set invitation', { + invitationId: invitation.id, + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + }) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index 33a60c2af5..aaaeecfc4f 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetMemberships') @@ -37,3 +38,77 @@ export async function GET() { return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 }) } } + +/** + * Leave a credential set (self-revocation). + * Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up. + */ +export async function DELETE(req: NextRequest) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const credentialSetId = searchParams.get('credentialSetId') + + if (!credentialSetId) { + return NextResponse.json({ error: 'credentialSetId is required' }, { status: 400 }) + } + + try { + const requestId = crypto.randomUUID().slice(0, 8) + + // Use transaction to ensure revocation + webhook sync are atomic + await db.transaction(async (tx) => { + // Find and verify membership + const [membership] = await tx + .select() + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, credentialSetId), + eq(credentialSetMember.userId, session.user.id) + ) + ) + .limit(1) + + if (!membership) { + throw new Error('Not a member of this credential set') + } + + if (membership.status === 'revoked') { + throw new Error('Already left this credential set') + } + + // Set status to 'revoked' - this immediately blocks credential from being used + await tx + .update(credentialSetMember) + .set({ + status: 'revoked', + updatedAt: new Date(), + }) + .where(eq(credentialSetMember.id, membership.id)) + + // Sync webhooks to remove this user's credential webhooks + const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId, tx) + logger.info('Synced webhooks after member left', { + credentialSetId, + userId: session.user.id, + ...syncResult, + }) + }) + + logger.info('User left credential set', { + credentialSetId, + userId: session.user.id, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to leave credential set' + logger.error('Error leaving credential set', error) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 5456eb631b..28f3180b3a 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -573,6 +573,10 @@ export async function POST(request: NextRequest) { blockId, provider, providerConfig: resolvedProviderConfig, + credentialSetId: + ((resolvedProviderConfig as Record)?.credentialSetId as + | string + | null) || null, isActive: true, updatedAt: new Date(), }) @@ -596,6 +600,10 @@ export async function POST(request: NextRequest) { path: finalPath, provider, providerConfig: resolvedProviderConfig, + credentialSetId: + ((resolvedProviderConfig as Record)?.credentialSetId as + | string + | null) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 43957ad95a..5995182ad6 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -374,6 +374,7 @@ async function upsertWebhookRecord( return } + const providerConfig = metadata.providerConfig as Record await db.insert(webhook).values({ id: webhookId, workflowId, @@ -381,6 +382,7 @@ async function upsertWebhookRecord( path: metadata.triggerPath, provider: metadata.provider, providerConfig: metadata.providerConfig, + credentialSetId: (providerConfig?.credentialSetId as string | null) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx index 8f38c485c6..fd847cfabf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx @@ -1,14 +1,13 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowLeft, Check, Copy, Loader2, Plus, Trash2, User } from 'lucide-react' +import { ArrowLeft, Loader2, LogOut, Plus, Trash2, User } from 'lucide-react' import { Avatar, AvatarFallback, AvatarImage, Button, - Combobox, Input, Label, Modal, @@ -16,11 +15,12 @@ import { ModalContent, ModalFooter, ModalHeader, + Textarea, } from '@/components/emcn' +import { GmailIcon, OutlookIcon } from '@/components/icons' import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client' -import { OAUTH_PROVIDERS } from '@/lib/oauth' import { getUserRole } from '@/lib/workspaces/organization' import { type CredentialSet, @@ -32,12 +32,13 @@ import { useCredentialSetMembers, useCredentialSetMemberships, useCredentialSets, + useLeaveCredentialSet, useRemoveCredentialSetMember, } from '@/hooks/queries/credential-sets' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' -const logger = createLogger('CredentialSets') +const logger = createLogger('EmailPolling') function CredentialSetsSkeleton() { return ( @@ -78,43 +79,89 @@ export function CredentialSets() { const [viewingSet, setViewingSet] = useState(null) const [newSetName, setNewSetName] = useState('') const [newSetDescription, setNewSetDescription] = useState('') - const [newSetType, setNewSetType] = useState('all') - const [newSetProviderId, setNewSetProviderId] = useState('') - const [inviteEmail, setInviteEmail] = useState('') - const [copiedLink, setCopiedLink] = useState(false) - const [generatedInviteUrl, setGeneratedInviteUrl] = useState(null) + const [newSetProvider, setNewSetProvider] = useState<'gmail' | 'outlook'>('gmail') + const [inviteEmails, setInviteEmails] = useState('') + const [isDragging, setIsDragging] = useState(false) + const [leavingMembership, setLeavingMembership] = useState<{ + credentialSetId: string + name: string + } | null>(null) const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id) const removeMember = useRemoveCredentialSetMember() + const leaveCredentialSet = useLeaveCredentialSet() - const providerOptions = useMemo(() => { - const options: { label: string; value: string }[] = [] - for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) { - if (provider.services) { - for (const [, service] of Object.entries(provider.services)) { - options.push({ - label: service.name, - value: service.providerId, + const getProviderName = useCallback((providerId: string) => { + if (providerId === 'gmail') return 'Gmail' + if (providerId === 'outlook') return 'Outlook' + return providerId + }, []) + + const extractEmailsFromText = useCallback((text: string): string[] => { + // Match email patterns in text + const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g + const matches = text.match(emailRegex) || [] + // Deduplicate and return + return [...new Set(matches.map((e) => e.toLowerCase()))] + }, []) + + const handleFileDrop = useCallback( + async (file: File) => { + try { + const text = await file.text() + const emails = extractEmailsFromText(text) + if (emails.length > 0) { + setInviteEmails((prev) => { + const existing = prev + .split(/[,\n]/) + .map((e) => e.trim()) + .filter((e) => e.length > 0) + const combined = [...new Set([...existing, ...emails])] + return combined.join('\n') }) } + } catch (error) { + logger.error('Error reading dropped file', error) } - } - return options.sort((a, b) => a.label.localeCompare(b.label)) + }, + [extractEmailsFromText] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + setIsDragging(true) }, []) - const getProviderName = useCallback((providerId: string) => { - for (const [, provider] of Object.entries(OAUTH_PROVIDERS)) { - if (provider.services) { - for (const [, service] of Object.entries(provider.services)) { - if (service.providerId === providerId) { - return service.name - } - } - } - } - return providerId + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) }, []) + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = Array.from(e.dataTransfer.files) + const validFiles = files.filter( + (f) => + f.type === 'text/csv' || + f.type === 'text/plain' || + f.name.endsWith('.csv') || + f.name.endsWith('.txt') + ) + + for (const file of validFiles) { + await handleFileDrop(file) + } + }, + [handleFileDrop] + ) + const handleRemoveMember = useCallback( async (memberId: string) => { if (!viewingSet) return @@ -130,6 +177,20 @@ export function CredentialSets() { [viewingSet, removeMember] ) + const handleLeave = useCallback((credentialSetId: string, name: string) => { + setLeavingMembership({ credentialSetId, name }) + }, []) + + const confirmLeave = useCallback(async () => { + if (!leavingMembership) return + try { + await leaveCredentialSet.mutateAsync(leavingMembership.credentialSetId) + setLeavingMembership(null) + } catch (error) { + logger.error('Failed to leave polling group', error) + } + }, [leavingMembership, leaveCredentialSet]) + const handleAcceptInvitation = useCallback( async (token: string) => { try { @@ -143,73 +204,53 @@ export function CredentialSets() { const handleCreateCredentialSet = useCallback(async () => { if (!newSetName.trim() || !activeOrganization?.id) return - if (newSetType === 'specific' && !newSetProviderId) return try { await createCredentialSet.mutateAsync({ organizationId: activeOrganization.id, name: newSetName.trim(), description: newSetDescription.trim() || undefined, - type: newSetType, - providerId: newSetType === 'specific' ? newSetProviderId : undefined, + type: 'specific' as CredentialSetType, + providerId: newSetProvider, }) setShowCreateModal(false) setNewSetName('') setNewSetDescription('') - setNewSetType('all') - setNewSetProviderId('') + setNewSetProvider('gmail') } catch (error) { - logger.error('Failed to create credential set', error) + logger.error('Failed to create polling group', error) } - }, [ - newSetName, - newSetDescription, - newSetType, - newSetProviderId, - activeOrganization?.id, - createCredentialSet, - ]) + }, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet]) const handleCreateInvite = useCallback(async () => { if (!selectedSetId) return + + // Parse comma-separated or newline-separated emails + const emails = inviteEmails + .split(/[,\n]/) + .map((e) => e.trim()) + .filter((e) => e.length > 0 && e.includes('@')) + + if (emails.length === 0) return + try { - const result = await createInvitation.mutateAsync({ - credentialSetId: selectedSetId, - email: inviteEmail.trim() || undefined, - }) - const inviteUrl = result.invitation?.inviteUrl - if (inviteUrl) { - setGeneratedInviteUrl(inviteUrl) - try { - await navigator.clipboard.writeText(inviteUrl) - setCopiedLink(true) - setTimeout(() => setCopiedLink(false), 2000) - } catch { - // Clipboard failed, URL is shown in modal for manual copy - } + for (const email of emails) { + await createInvitation.mutateAsync({ + credentialSetId: selectedSetId, + email, + }) } - setInviteEmail('') + setInviteEmails('') + setShowInviteModal(false) + setSelectedSetId(null) } catch (error) { - logger.error('Failed to create invitation', error) + logger.error('Failed to create invitations', error) } - }, [selectedSetId, inviteEmail, createInvitation]) - - const handleCopyInviteUrl = useCallback(async () => { - if (!generatedInviteUrl) return - try { - await navigator.clipboard.writeText(generatedInviteUrl) - setCopiedLink(true) - setTimeout(() => setCopiedLink(false), 2000) - } catch { - // Fallback: select the input text - } - }, [generatedInviteUrl]) + }, [selectedSetId, inviteEmails, createInvitation]) const handleCloseInviteModal = useCallback(() => { setShowInviteModal(false) - setInviteEmail('') + setInviteEmails('') setSelectedSetId(null) - setGeneratedInviteUrl(null) - setCopiedLink(false) }, []) if (membershipsLoading || invitationsLoading) { @@ -220,7 +261,7 @@ export function CredentialSets() { const hasNoContent = invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0 - // Detail view for a credential set + // Detail view for a polling group if (viewingSet) { const activeMembers = members.filter((m) => m.status === 'active') const pendingMembers = members.filter((m) => m.status === 'pending') @@ -236,9 +277,7 @@ export function CredentialSets() { {viewingSet.name} - {viewingSet.type === 'all' - ? 'All Integrations' - : `${getProviderName(viewingSet.providerId || '')} Only`} + {getProviderName(viewingSet.providerId || '')} @@ -325,7 +364,7 @@ export function CredentialSets() {

No members yet

- Invite people to join this credential set + Invite people to join this polling group

)} @@ -341,79 +380,58 @@ export function CredentialSets() { }} > - Invite Member + Add Member - Invite to Credential Set + Add Members
- {generatedInviteUrl ? ( - <> -
- -
- (e.target as HTMLInputElement).select()} - /> - +
+ +
+