feat(polling-groups): can invite multiple people to have their gmail/outlook inboxes connected to a workflow (#2695)

* progress on cred sets

* fix credential set system

* return data to render credential set in block preview

* progress

* invite flow

* simplify code

* fix ui

* fix tests

* fix types

* fix

* fix icon for outlook

* fix cred set name not showing up for owner

* fix rendering of credential set name

* fix outlook well known folder id resolution

* fix perms for creating cred set

* add to docs and simplify ui

* consolidate webhook code better

* fix tests

* fix credential collab logic issue

* fix ui

* fix lint
This commit is contained in:
Vikhyath Mondreti
2026-01-07 17:49:40 -08:00
committed by GitHub
parent cb12ceb82c
commit 020037728d
59 changed files with 14775 additions and 320 deletions

View File

@@ -33,6 +33,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
<Card title="RSS Feed" href="/triggers/rss">
Monitor RSS and Atom feeds for new content
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
Monitor team Gmail and Outlook inboxes
</Card>
</Cards>
## Quick Comparison
@@ -43,6 +46,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
| **RSS Feed** | New item published to feed |
| **Email Polling Groups** | New email received in team Gmail or Outlook inboxes |
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
@@ -66,3 +70,24 @@ If your workflow has multiple triggers, the highest priority trigger will be exe
**External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing.
## Email Polling Groups
Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes with a single trigger. Requires a Team or Enterprise plan.
**Creating a Polling Group** (Admin/Owner)
1. Go to **Settings → Email Polling**
2. Click **Create** and choose Gmail or Outlook
3. Enter a name for the group
**Inviting Members**
1. Click **Add Members** on your polling group
2. Enter email addresses (comma or newline separated, or drag & drop a CSV)
3. Click **Send Invites**
Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization.
**Using in a Workflow**
When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow.

View File

@@ -109,11 +109,15 @@ function SignupFormContent({
setEmail(emailParam)
}
const redirectParam = searchParams.get('redirect')
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
if (redirectParam) {
setRedirectUrl(redirectParam)
if (redirectParam.startsWith('/invite/')) {
if (
redirectParam.startsWith('/invite/') ||
redirectParam.startsWith('/credential-account/')
) {
setIsInviteFlow(true)
}
}

View File

@@ -8,11 +8,18 @@ import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/ut
describe('OAuth Disconnect API Route', () => {
const mockGetSession = vi.fn()
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
}
const mockDb = {
delete: vi.fn().mockReturnThis(),
where: vi.fn(),
select: vi.fn().mockReturnValue(mockSelectChain),
}
const mockLogger = createMockLogger()
const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({})
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
@@ -33,6 +40,13 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
credentialSetMember: {
id: 'id',
credentialSetId: 'credentialSetId',
userId: 'userId',
status: 'status',
},
credentialSet: { id: 'id', providerId: 'providerId' },
}))
vi.doMock('drizzle-orm', () => ({
@@ -45,6 +59,14 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.doMock('@/lib/webhooks/utils.server', () => ({
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
}))
})
afterEach(() => {

View File

@@ -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,49 @@ 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,
})
.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
// Credential sets store OAuth provider IDs like 'google-email' or 'outlook'
const matchesProvider =
membership.providerId === provider ||
membership.providerId === providerId ||
membership.providerId?.startsWith(`${provider}-`)
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)

View File

@@ -138,7 +138,10 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Credential ID is required')
expect(data).toHaveProperty(
'error',
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
expect(mockLogger.warn).toHaveBeenCalled()
})

View File

@@ -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,37 @@ 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,
})
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 })
} 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) {
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 +103,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 +110,6 @@ export async function POST(request: NextRequest) {
}
try {
// Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
let instanceUrl: string | undefined
@@ -145,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) {

View File

@@ -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'
@@ -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)
@@ -335,3 +335,108 @@ 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<CredentialSetCredential[]> {
logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`)
const members = await db
.select({ userId: credentialSetMember.userId })
.from(credentialSetMember)
.where(
and(
eq(credentialSetMember.credentialSetId, credentialSetId),
eq(credentialSetMember.status, 'active')
)
)
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({
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)))
logger.info(
`Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members`
)
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<string, unknown> = {
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
}

View File

@@ -0,0 +1,146 @@
import { db } from '@sim/db'
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 { 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('CredentialSetInviteResend')
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
const [set] = await db
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
name: credentialSet.name,
providerId: credentialSet.providerId,
})
.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 POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id, invitationId } = 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' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const [invitation] = await db
.select()
.from(credentialSetInvitation)
.where(
and(
eq(credentialSetInvitation.id, invitationId),
eq(credentialSetInvitation.credentialSetId, id)
)
)
.limit(1)
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Only pending invitations can be resent' }, { status: 400 })
}
// Update expiration
const newExpiresAt = new Date()
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
await db
.update(credentialSetInvitation)
.set({ expiresAt: newExpiresAt })
.where(eq(credentialSetInvitation.id, invitationId))
const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}`
// Send email if email address exists
if (invitation.email) {
try {
const [inviter] = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
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 'google-email' | 'outlook') || 'google-email'
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: invitation.email,
subject: getEmailSubject('polling-group-invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.warn('Failed to resend invitation email', {
email: invitation.email,
error: emailResult.message,
})
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
} catch (emailError) {
logger.error('Error sending invitation email', emailError)
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
}
logger.info('Resent credential set invitation', {
credentialSetId: id,
invitationId,
userId: session.user.id,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error resending invitation', error)
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
}
}

View File

@@ -0,0 +1,215 @@
import { db } from '@sim/db'
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')
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,
providerId: credentialSet.providerId,
})
.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' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner 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 = `${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 'google-email' | 'outlook') || 'google-email'
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({
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' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner 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 })
}
}

View File

@@ -0,0 +1,166 @@
import { db } from '@sim/db'
import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
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')
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
const [set] = await db
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
providerId: credentialSet.providerId,
})
.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))
// Get credentials for all active members filtered by the polling group's provider
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 && 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))
)
}
// 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<string, { providerId: string; accountId: string }[]>
)
// 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 }> }) {
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' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner 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 })
}
const requestId = crypto.randomUUID().slice(0, 8)
// Use transaction to ensure member deletion + webhook sync are atomic
await db.transaction(async (tx) => {
await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx)
logger.info('Synced webhooks after member removed', {
credentialSetId: id,
...syncResult,
})
})
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 })
}
}

View File

@@ -0,0 +1,155 @@
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,
providerId: credentialSet.providerId,
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' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner 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' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner 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 })
}
}

View File

@@ -0,0 +1,53 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, gt, isNull, 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,
providerId: credentialSet.providerId,
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),
isNull(credentialSetInvitation.email)
),
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 })
}
}

View File

@@ -0,0 +1,196 @@
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'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
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,
providerId: credentialSet.providerId,
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,
providerId: invitation.providerId,
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 [invitationData] = await db
.select({
id: credentialSetInvitation.id,
credentialSetId: credentialSetInvitation.credentialSetId,
email: credentialSetInvitation.email,
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
invitedBy: credentialSetInvitation.invitedBy,
providerId: credentialSet.providerId,
})
.from(credentialSetInvitation)
.innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
.where(eq(credentialSetInvitation.token, token))
.limit(1)
if (!invitationData) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
const invitation = invitationData
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 })
}
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()
const requestId = crypto.randomUUID().slice(0, 8)
// 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,
})
await tx
.update(credentialSetInvitation)
.set({
status: 'accepted',
acceptedAt: now,
acceptedByUserId: session.user.id,
})
.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 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')
)
)
}
// Sync webhooks within the transaction
const syncResult = await syncAllWebhooksForCredentialSet(
invitation.credentialSetId,
requestId,
tx
)
logger.info('Synced webhooks after member joined', {
credentialSetId: invitation.credentialSetId,
...syncResult,
})
})
logger.info('Accepted credential set invitation', {
invitationId: invitation.id,
credentialSetId: invitation.credentialSetId,
userId: session.user.id,
})
return NextResponse.json({
success: true,
credentialSetId: invitation.credentialSetId,
providerId: invitation.providerId,
})
} catch (error) {
logger.error('Error accepting invitation', error)
return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 })
}
}

View File

@@ -0,0 +1,115 @@
import { db } from '@sim/db'
import { credentialSet, 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'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
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,
providerId: credentialSet.providerId,
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 })
}
}
/**
* 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 })
}
}

View File

@@ -0,0 +1,157 @@
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(),
providerId: z.enum(['google-email', 'outlook']),
})
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,
providerId: credentialSet.providerId,
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, providerId } = 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)
const role = membership[0]?.role
if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) {
return NextResponse.json(
{ error: 'Admin or owner 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,
providerId,
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 })
}
}

View File

@@ -15,8 +15,11 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation')
@@ -69,6 +72,102 @@ export async function GET(
}
}
// Resend invitation
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Verify user is admin/owner
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
const orgInvitation = await db
.select()
.from(invitation)
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
.then((rows) => rows[0])
if (!orgInvitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
if (orgInvitation.status !== 'pending') {
return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
}
const org = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.then((rows) => rows[0])
const inviter = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
// Update expiration date
const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
await db
.update(invitation)
.set({ expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId))
// Send email
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
org?.name || 'organization',
`${getBaseUrl()}/invite/${invitationId}`
)
const emailResult = await sendEmail({
to: orgInvitation.email,
subject: getEmailSubject('invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.error('Failed to resend invitation email', {
email: orgInvitation.email,
error: emailResult.message,
})
return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 })
}
logger.info('Organization invitation resent', {
organizationId,
invitationId,
resentBy: session.user.id,
email: orgInvitation.email,
})
return NextResponse.json({
success: true,
message: 'Invitation resent successfully',
})
} catch (error) {
logger.error('Error resending organization invitation:', error)
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
}
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }

View File

@@ -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<string, unknown>) || {}
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<string, unknown> | 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<string, unknown> | 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`, {

View File

@@ -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
@@ -422,6 +573,10 @@ export async function POST(request: NextRequest) {
blockId,
provider,
providerConfig: resolvedProviderConfig,
credentialSetId:
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
updatedAt: new Date(),
})
@@ -445,6 +600,10 @@ export async function POST(request: NextRequest) {
path: finalPath,
provider,
providerConfig: resolvedProviderConfig,
credentialSetId:
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -157,6 +157,112 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/webhooks/processor', () => ({
findAllWebhooksForPath: vi.fn().mockImplementation(async (options: { path: string }) => {
// Filter webhooks by path from globalMockData
const matchingWebhooks = globalMockData.webhooks.filter(
(wh) => wh.path === options.path && wh.isActive
)
if (matchingWebhooks.length === 0) {
return []
}
// Return array of {webhook, workflow} objects
return matchingWebhooks.map((wh) => {
const matchingWorkflow = globalMockData.workflows.find((w) => w.id === wh.workflowId) || {
id: wh.workflowId || 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
}
return {
webhook: wh,
workflow: matchingWorkflow,
}
})
}),
parseWebhookBody: vi.fn().mockImplementation(async (request: any) => {
try {
const cloned = request.clone()
const rawBody = await cloned.text()
const body = rawBody ? JSON.parse(rawBody) : {}
return { body, rawBody }
} catch {
return { body: {}, rawBody: '' }
}
}),
handleProviderChallenges: vi.fn().mockResolvedValue(null),
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
verifyProviderAuth: vi
.fn()
.mockImplementation(
async (
foundWebhook: any,
_foundWorkflow: any,
request: any,
_rawBody: string,
_requestId: string
) => {
// Implement generic webhook auth verification for tests
if (foundWebhook.provider === 'generic') {
const providerConfig = foundWebhook.providerConfig || {}
if (providerConfig.requireAuth) {
const configToken = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName
if (configToken) {
let isTokenValid = false
if (secretHeaderName) {
// Custom header auth
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
if (headerValue === configToken) {
isTokenValid = true
}
} else {
// Bearer token auth
const authHeader = request.headers.get('authorization')
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7)
if (token === configToken) {
isTokenValid = true
}
}
}
if (!isTokenValid) {
const { NextResponse } = await import('next/server')
return new NextResponse('Unauthorized - Invalid authentication token', {
status: 401,
})
}
} else {
// Auth required but no token configured
const { NextResponse } = await import('next/server')
return new NextResponse('Unauthorized - Authentication required but not configured', {
status: 401,
})
}
}
}
return null
}
),
checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
const { NextResponse } = require('next/server')
return NextResponse.json({ error }, { status })
}),
shouldSkipWebhookEvent: vi.fn().mockReturnValue(false),
handlePreDeploymentVerification: vi.fn().mockReturnValue(null),
queueWebhookExecution: vi.fn().mockImplementation(async () => {
// Call processWebhookMock so tests can verify it was called
processWebhookMock()
const { NextResponse } = await import('next/server')
return NextResponse.json({ message: 'Webhook processed' })
}),
}))
vi.mock('drizzle-orm/postgres-js', () => ({
drizzle: vi.fn().mockReturnValue({}),
}))
@@ -165,6 +271,10 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
import { POST } from '@/app/api/webhooks/trigger/[path]/route'

View File

@@ -3,11 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
findWebhookAndWorkflow,
findAllWebhooksForPath,
formatProviderErrorResponse,
handlePreDeploymentVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
queueWebhookExecution,
shouldSkipWebhookEvent,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
@@ -22,19 +25,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const requestId = generateRequestId()
const { path } = await params
// Handle Microsoft Graph subscription validation
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
// Handle other GET-based verifications if needed
// Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.)
const challengeResponse = await handleProviderChallenges({}, request, requestId, path)
if (challengeResponse) {
return challengeResponse
@@ -50,26 +41,10 @@ export async function POST(
const requestId = generateRequestId()
const { path } = await params
// Log ALL incoming webhook requests for debugging
logger.info(`[${requestId}] Incoming webhook request`, {
path,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
})
// Handle Microsoft Graph subscription validation (some environments send POST with validationToken)
try {
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation (POST) for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
} catch {
// ignore URL parsing errors; proceed to normal handling
// Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
if (earlyChallenge) {
return earlyChallenge
}
const parseResult = await parseWebhookBody(request, requestId)
@@ -86,118 +61,118 @@ 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[] = []
// 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,
})
}
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
request,
rawBody,
requestId
)
if (authError) {
return authError
}
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
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 }
)
}
return NextResponse.json(
{ error: 'An unexpected error occurred during preprocessing' },
{ status: 500 }
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
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
}
if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
// For Grain, if block doesn't exist in deployment, treat as verification request
// Grain validates webhook URLs during creation, and the block may not be deployed yet
if (foundWebhook.provider === 'grain') {
logger.info(
`[${requestId}] Grain webhook verification - block not in deployment, returning 200 OK`
)
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
// Reachability test should return immediately for the first webhook
return reachabilityResponse
}
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,
})
if (webhooksForPath.length > 1) {
continue
}
logger.info(
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
return formatProviderErrorResponse(
foundWebhook,
'An unexpected error occurred during preprocessing',
500
)
return new NextResponse('Trigger block not found in deployment', { status: 404 })
}
}
if (foundWebhook.provider === 'stripe') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const eventTypes = providerConfig.eventTypes
if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
const preDeploymentResponse = handlePreDeploymentVerification(foundWebhook, requestId)
if (preDeploymentResponse) {
return preDeploymentResponse
}
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`
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
)
return new NextResponse('Event type filtered', { status: 200 })
if (webhooksForPath.length > 1) {
continue
}
return new NextResponse('Trigger block not found in deployment', { status: 404 })
}
}
if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) {
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,
})
}

View File

@@ -317,6 +317,8 @@ interface WebhookMetadata {
providerConfig: Record<string, any>
}
const CREDENTIAL_SET_PREFIX = 'credentialSet:'
function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerId =
getSubBlockValue<string>(block, 'triggerId') ||
@@ -328,9 +330,17 @@ function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerDef = triggerId ? getTrigger(triggerId) : undefined
const provider = triggerDef?.provider || null
// Handle credential sets vs individual credentials
const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
? triggerCredentials!.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
const credentialId = isCredentialSet ? undefined : triggerCredentials
const providerConfig = {
...(typeof triggerConfig === 'object' ? triggerConfig : {}),
...(triggerCredentials ? { credentialId: triggerCredentials } : {}),
...(credentialId ? { credentialId } : {}),
...(credentialSetId ? { credentialSetId } : {}),
...(triggerId ? { triggerId } : {}),
}
@@ -347,6 +357,54 @@ async function upsertWebhookRecord(
webhookId: string,
metadata: WebhookMetadata
): Promise<void> {
const providerConfig = metadata.providerConfig as Record<string, unknown>
const credentialSetId = providerConfig?.credentialSetId as string | undefined
// For credential sets, delegate to the sync function which handles fan-out
if (credentialSetId && metadata.provider) {
const { syncWebhooksForCredentialSet } = await import('@/lib/webhooks/utils.server')
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
const oauthProviderId = getProviderIdFromServiceId(metadata.provider)
const requestId = crypto.randomUUID().slice(0, 8)
// Extract base config (without credential-specific fields)
const {
credentialId: _cId,
credentialSetId: _csId,
userId: _uId,
...baseConfig
} = providerConfig
try {
await syncWebhooksForCredentialSet({
workflowId,
blockId: block.id,
provider: metadata.provider,
basePath: metadata.triggerPath,
credentialSetId,
oauthProviderId,
providerConfig: baseConfig as Record<string, any>,
requestId,
})
logger.info('Synced credential set webhooks during workflow save', {
workflowId,
blockId: block.id,
credentialSetId,
})
} catch (error) {
logger.error('Failed to sync credential set webhooks during workflow save', {
workflowId,
blockId: block.id,
credentialSetId,
error,
})
}
return
}
// For individual credentials, use the existing single webhook logic
const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1)
if (existing) {
@@ -381,6 +439,7 @@ async function upsertWebhookRecord(
path: metadata.triggerPath,
provider: metadata.provider,
providerConfig: metadata.providerConfig,
credentialSetId: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -0,0 +1,269 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Mail } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { client, useSession } from '@/lib/auth/auth-client'
import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
interface InvitationInfo {
credentialSetName: string
organizationName: string
providerId: string | null
email: string | null
}
type AcceptedState = 'connecting' | 'already-connected'
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<InvitationInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [accepting, setAccepting] = useState(false)
const [acceptedState, setAcceptedState] = useState<AcceptedState | null>(null)
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) {
// Include invite_flow=true so the login page preserves callbackUrl when linking to signup
const callbackUrl = encodeURIComponent(`/credential-account/${token}`)
router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`)
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
}
const data = await res.json()
const credentialSetProviderId = data.providerId || invitation?.providerId
// Check if user already has this provider connected
let isAlreadyConnected = false
if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
try {
const connectionsRes = await fetch('/api/auth/oauth/connections')
if (connectionsRes.ok) {
const connectionsData = await connectionsRes.json()
const connections = connectionsData.connections || []
isAlreadyConnected = connections.some(
(conn: { provider: string; accounts?: { id: string }[] }) =>
conn.provider === credentialSetProviderId &&
conn.accounts &&
conn.accounts.length > 0
)
}
} catch {
// If we can't check connections, proceed with OAuth flow
}
}
if (isAlreadyConnected) {
// Already connected - redirect to workspace
setAcceptedState('already-connected')
setTimeout(() => {
router.push('/workspace')
}, 2000)
} else if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
// Not connected - start OAuth flow
setAcceptedState('connecting')
// Small delay to show success message before redirect
setTimeout(async () => {
try {
await client.oauth2.link({
providerId: credentialSetProviderId,
callbackURL: `${window.location.origin}/workspace`,
})
} catch (oauthError) {
// OAuth redirect will happen, this catch is for any pre-redirect errors
console.error('OAuth initiation error:', oauthError)
// If OAuth fails, redirect to workspace where they can connect manually
router.push('/workspace')
}
}, 1500)
} else {
// No provider specified - just redirect to workspace
router.push('/workspace')
}
} catch {
setError('Failed to accept invitation')
} finally {
setAccepting(false)
}
}, [session?.user?.id, token, router, invitation?.providerId])
const providerName = invitation?.providerId
? getProviderDisplayName(invitation.providerId)
: 'email'
const ProviderIcon =
invitation?.providerId === 'outlook'
? OutlookIcon
: invitation?.providerId === 'google-email'
? GmailIcon
: Mail
const providerWithIcon = (
<span className='inline-flex items-baseline gap-1'>
<ProviderIcon className='inline-block h-4 w-4 translate-y-[2px]' />
{providerName}
</span>
)
const getCallbackUrl = () => `/credential-account/${token}`
if (loading || sessionLoading) {
return (
<InviteLayout>
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
</InviteLayout>
)
}
if (error) {
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Unable to load invitation'
description={error}
icon='error'
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
},
]}
/>
</InviteLayout>
)
}
if (acceptedState === 'already-connected') {
return (
<InviteLayout>
<InviteStatusCard
type='success'
title="You're all set!"
description={`You've joined ${invitation?.credentialSetName}. Your ${providerName} account is already connected. Redirecting to workspace...`}
icon='success'
/>
</InviteLayout>
)
}
if (acceptedState === 'connecting') {
return (
<InviteLayout>
<InviteStatusCard
type='loading'
title={`Connecting to ${providerName}...`}
description={`You've joined ${invitation?.credentialSetName}. You'll be redirected to connect your ${providerName} account.`}
/>
</InviteLayout>
)
}
// Not logged in
if (!session?.user) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
return (
<InviteLayout>
<InviteStatusCard
type='login'
title='Join Email Polling Group'
description={`You've been invited to join ${invitation?.credentialSetName} by ${invitation?.organizationName}. Sign in or create an account to accept this invitation.`}
icon='mail'
actions={[
{
label: 'Sign in',
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'Create an account',
onClick: () =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
variant: 'outline' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Logged in - show invitation
return (
<InviteLayout>
<InviteStatusCard
type='invitation'
title='Join Email Polling Group'
description={
<>
You've been invited to join {invitation?.credentialSetName} by{' '}
{invitation?.organizationName}.
{invitation?.providerId && (
<> You'll be asked to connect your {providerWithIcon} account after accepting.</>
)}
</>
}
icon='mail'
actions={[
{
label: `Accept & Connect ${providerName}`,
onClick: handleAccept,
disabled: accepting,
loading: accepting,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}

View File

@@ -2,8 +2,10 @@
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 { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -15,7 +17,11 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { 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'
@@ -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,20 @@ 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 isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, isForeign])
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
useEffect(() => {
if (!isEditing) {
@@ -148,6 +181,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 +218,56 @@ export function CredentialSelector({
.join(' ')
}, [])
const comboboxOptions = useMemo(() => {
const { comboboxOptions, comboboxGroups } = useMemo(() => {
const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId)
// Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility
const matchesProvider = (csProviderId: string | null) => {
if (!csProviderId || !pollingProviderId) return false
if (csProviderId === pollingProviderId) return true
// Handle legacy 'gmail' mapping to 'google-email'
if (pollingProviderId === 'google-email' && csProviderId === 'gmail') return true
return false
}
const filteredCredentialSets = pollingProviderId
? credentialSets.filter((cs) => matchesProvider(cs.providerId))
: []
if (canUseCredentialSets && filteredCredentialSets.length > 0) {
const groups = []
groups.push({
section: 'Polling Groups',
items: filteredCredentialSets.map((cs) => ({
label: cs.name,
value: `${CREDENTIAL_SET.PREFIX}${cs.id}`,
})),
})
const credentialItems = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
}))
if (credentialItems.length > 0) {
groups.push({
section: 'Personal Credential',
items: credentialItems,
})
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
return { comboboxOptions: [], comboboxGroups: groups }
}
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
@@ -189,14 +280,32 @@ export function CredentialSelector({
})
}
return options
}, [credentials, provider, getProviderName])
return { comboboxOptions: options, comboboxGroups: undefined }
}, [
credentials,
provider,
effectiveProviderId,
getProviderName,
canUseCredentialSets,
credentialSets,
])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
if (isCredentialSetSelected && selectedCredentialSet) {
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
<Users className='h-3 w-3' />
</div>
<span className='truncate'>{inputValue}</span>
</div>
)
}
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
@@ -205,7 +314,13 @@ export function CredentialSelector({
<span className='truncate'>{inputValue}</span>
</div>
)
}, [getProviderIcon, inputValue, selectedCredentialProvider])
}, [
getProviderIcon,
inputValue,
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
])
const handleComboboxChange = useCallback(
(value: string) => {
@@ -214,6 +329,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 +349,16 @@ export function CredentialSelector({
setIsEditing(true)
setInputValue(value)
},
[credentials, handleAddCredential, handleSelect]
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
)
return (
<div>
<Combobox
options={comboboxOptions}
groups={comboboxGroups}
value={inputValue}
selectedValue={selectedId}
selectedValue={rawSelectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={
@@ -240,10 +366,10 @@ export function CredentialSelector({
}
disabled={effectiveDisabled}
editable={true}
filterOptions={true}
filterOptions={!isForeign && !isForeignCredentialSet}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
/>
{needsUpdate && (

View File

@@ -10,6 +10,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CREDENTIAL } from '@/executor/constants'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -95,7 +96,7 @@ export function ToolCredentialSelector({
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, isForeign])
@@ -210,7 +211,7 @@ export function ToolCredentialSelector({
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
filterOptions={!isForeign}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}

View File

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

View File

@@ -1,7 +1,7 @@
'use client'
import React, { useMemo, useState } from 'react'
import { CheckCircle, ChevronDown } from 'lucide-react'
import { ChevronDown } from 'lucide-react'
import {
Button,
Checkbox,
@@ -302,14 +302,11 @@ export function MemberInvitationCard({
{/* Success message */}
{inviteSuccess && (
<div className='flex items-start gap-[8px] rounded-[6px] bg-green-500/10 px-[10px] py-[8px] text-green-600 dark:text-green-400'>
<CheckCircle className='h-4 w-4 flex-shrink-0' />
<p className='text-[12px]'>
Invitation sent successfully
{selectedCount > 0 &&
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
</p>
</div>
<p className='text-[11px] text-[var(--text-success)] leading-tight'>
Invitation sent successfully
{selectedCount > 0 &&
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
</p>
)}
</div>
</div>

View File

@@ -5,7 +5,11 @@ import { createLogger } from '@sim/logger'
import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { useCancelInvitation, useOrganizationMembers } from '@/hooks/queries/organization'
import {
useCancelInvitation,
useOrganizationMembers,
useResendInvitation,
} from '@/hooks/queries/organization'
const logger = createLogger('TeamMembers')
@@ -46,12 +50,16 @@ export function TeamMembers({
onRemoveMember,
}: TeamMembersProps) {
const [cancellingInvitations, setCancellingInvitations] = useState<Set<string>>(new Set())
const [resendingInvitations, setResendingInvitations] = useState<Set<string>>(new Set())
const [resentInvitations, setResentInvitations] = useState<Set<string>>(new Set())
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers(
organization?.id || ''
)
const cancelInvitationMutation = useCancelInvitation()
const resendInvitationMutation = useResendInvitation()
const memberUsageData: Record<string, number> = {}
if (memberUsageResponse?.data) {
@@ -140,6 +148,54 @@ export function TeamMembers({
}
}
const handleResendInvitation = async (invitationId: string) => {
if (!organization?.id) return
const secondsLeft = resendCooldowns[invitationId]
if (secondsLeft && secondsLeft > 0) return
setResendingInvitations((prev) => new Set([...prev, invitationId]))
try {
await resendInvitationMutation.mutateAsync({
invitationId,
orgId: organization.id,
})
setResentInvitations((prev) => new Set([...prev, invitationId]))
setTimeout(() => {
setResentInvitations((prev) => {
const next = new Set(prev)
next.delete(invitationId)
return next
})
}, 4000)
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
} catch (error) {
logger.error('Failed to resend invitation', { error })
} finally {
setResendingInvitations((prev) => {
const next = new Set(prev)
next.delete(invitationId)
return next
})
}
}
return (
<div className='flex flex-col gap-[16px]'>
{/* Header */}
@@ -148,13 +204,13 @@ export function TeamMembers({
</div>
{/* Members list */}
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
{teamItems.map((item) => (
<div key={item.id} className='flex items-center justify-between'>
{/* Left section: Avatar + Name/Role + Action buttons */}
<div className='flex flex-1 items-center gap-[12px]'>
{/* Avatar */}
<Avatar size='sm'>
<Avatar className='h-9 w-9'>
{item.avatarUrl && <AvatarImage src={item.avatarUrl} alt={item.name} />}
<AvatarFallback
style={{ background: getUserColor(item.userId || item.email) }}
@@ -179,32 +235,60 @@ export function TeamMembers({
</Badge>
)}
{item.type === 'invitation' && (
<Badge variant='amber' size='sm'>
<Badge variant='gray-secondary' size='sm'>
Pending
</Badge>
)}
</div>
<div className='truncate text-[12px] text-[var(--text-muted)]'>{item.email}</div>
<div className='truncate text-[13px] text-[var(--text-muted)]'>{item.email}</div>
</div>
{/* Action buttons */}
{isAdminOrOwner && (
<>
{/* Admin/Owner can remove other members */}
{item.type === 'member' &&
item.role !== 'owner' &&
item.email !== currentUserEmail && (
<Button
variant='ghost'
onClick={() => onRemoveMember(item.member)}
className='h-8'
>
Remove
</Button>
)}
{/* Action buttons for members */}
{isAdminOrOwner &&
item.type === 'member' &&
item.role !== 'owner' &&
item.email !== currentUserEmail && (
<Button
variant='ghost'
onClick={() => onRemoveMember(item.member)}
className='h-8'
>
Remove
</Button>
)}
</div>
{/* Admin can cancel invitations */}
{item.type === 'invitation' && (
{/* Right section */}
{isAdminOrOwner && (
<div className='ml-[16px] flex flex-col items-end'>
{item.type === 'member' ? (
<>
<div className='text-[12px] text-[var(--text-muted)]'>Usage</div>
<div className='font-medium text-[12px] text-[var(--text-primary)] tabular-nums'>
{isLoadingUsage ? (
<span className='inline-block h-3 w-12 animate-pulse rounded-[4px] bg-[var(--surface-4)]' />
) : (
item.usage
)}
</div>
</>
) : (
<div className='flex items-center gap-[4px]'>
<Button
variant='ghost'
onClick={() => handleResendInvitation(item.invitation.id)}
disabled={
resendingInvitations.has(item.invitation.id) ||
(resendCooldowns[item.invitation.id] ?? 0) > 0
}
className='h-8'
>
{resendingInvitations.has(item.invitation.id)
? 'Sending...'
: resendCooldowns[item.invitation.id]
? `Resend (${resendCooldowns[item.invitation.id]}s)`
: 'Resend'}
</Button>
<Button
variant='ghost'
onClick={() => handleCancelInvitation(item.invitation.id)}
@@ -213,22 +297,8 @@ export function TeamMembers({
>
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
</Button>
)}
</>
)}
</div>
{/* Right section: Usage column (right-aligned) */}
{isAdminOrOwner && (
<div className='ml-[16px] flex flex-col items-end'>
<div className='text-[12px] text-[var(--text-muted)]'>Usage</div>
<div className='font-medium text-[12px] text-[var(--text-primary)] tabular-nums'>
{isLoadingUsage && item.type === 'member' ? (
<span className='inline-block h-3 w-12 animate-pulse rounded-[4px] bg-[var(--surface-4)]' />
) : (
item.usage
)}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
import { Files, KeySquare, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
import { Files, KeySquare, LogIn, Mail, Server, Settings, User, Users, Wrench } from 'lucide-react'
import {
Card,
Connections,
@@ -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'
@@ -116,6 +118,13 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{
id: 'credential-sets',
label: 'Email Polling',
icon: Mail,
section: 'system',
requiresTeam: true,
},
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
@@ -462,6 +471,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
{activeSection === 'credential-sets' && <CredentialSets />}
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
{activeSection === 'files' && <FileUploads />}
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}

View File

@@ -2,6 +2,7 @@
import React, { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Paperclip, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -40,9 +41,12 @@ interface PendingInvitation {
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [inputValue, setInputValue] = useState('')
const [emails, setEmails] = useState<string[]>([])
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [duplicateEmails, setDuplicateEmails] = useState<string[]>([])
const [isDragging, setIsDragging] = useState(false)
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
@@ -134,13 +138,20 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid
if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
if (
emails.includes(normalized) ||
invalidEmails.includes(normalized) ||
duplicateEmails.includes(normalized)
) {
return false
}
const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized)
if (hasPendingInvitation) {
setErrorMessage(`${normalized} already has a pending invitation`)
setDuplicateEmails((prev) => {
if (prev.includes(normalized)) return prev
return [...prev, normalized]
})
setInputValue('')
return false
}
@@ -149,7 +160,10 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
(user) => user.email === normalized
)
if (isExistingMember) {
setErrorMessage(`${normalized} is already a member of this workspace`)
setDuplicateEmails((prev) => {
if (prev.includes(normalized)) return prev
return [...prev, normalized]
})
setInputValue('')
return false
}
@@ -161,13 +175,19 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
}
if (!isValid) {
setInvalidEmails((prev) => [...prev, normalized])
setInvalidEmails((prev) => {
if (prev.includes(normalized)) return prev
return [...prev, normalized]
})
setInputValue('')
return false
}
setErrorMessage(null)
setEmails((prev) => [...prev, normalized])
setEmails((prev) => {
if (prev.includes(normalized)) return prev
return [...prev, normalized]
})
setUserPermissions((prev) => [
...prev,
@@ -180,7 +200,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInputValue('')
return true
},
[emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email]
[
emails,
invalidEmails,
duplicateEmails,
pendingInvitations,
workspacePermissions?.users,
session?.user?.email,
]
)
const removeEmail = useCallback(
@@ -196,6 +223,80 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
const removeDuplicateEmail = useCallback((index: number) => {
setDuplicateEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
const extractEmailsFromText = useCallback((text: string): string[] => {
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
const matches = text.match(emailRegex) || []
return [...new Set(matches.map((e) => e.toLowerCase()))]
}, [])
const handleFileDrop = useCallback(
async (file: File) => {
try {
const text = await file.text()
const extractedEmails = extractEmailsFromText(text)
extractedEmails.forEach((email) => {
addEmail(email)
})
} catch (error) {
logger.error('Error reading dropped file', error)
}
},
[extractEmailsFromText, addEmail]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
setIsDragging(true)
}, [])
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 handleFileInputChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
await handleFileDrop(file)
}
e.target.value = ''
},
[handleFileDrop]
)
const handlePermissionChange = useCallback(
(identifier: string, permissionType: PermissionType) => {
const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier)
@@ -204,11 +305,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setExistingUserPermissionChanges((prev) => {
const newChanges = { ...prev }
// If the new permission matches the original, remove the change entry
if (existingUser.permissionType === permissionType) {
delete newChanges[identifier]
} else {
// Otherwise, track the change
newChanges[identifier] = { permissionType }
}
@@ -297,7 +396,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setErrorMessage(null)
try {
// Verify the user exists in workspace permissions
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
@@ -322,7 +420,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
throw new Error(data.error || 'Failed to remove member')
}
// Update the workspace permissions to remove the user
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
@@ -333,7 +430,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
})
}
// Clear any pending changes for this user
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
@@ -384,7 +480,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
throw new Error(data.error || 'Failed to cancel invitation')
}
// Remove the invitation from the pending invitations list
setPendingInvitations((prev) =>
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
)
@@ -452,7 +547,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
delete next[invitationId]
return next
})
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
@@ -474,40 +568,52 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
if (e.key === 'Enter') {
e.preventDefault()
if (inputValue.trim()) {
addEmail(inputValue)
}
return
}
if ([',', ' '].includes(e.key) && inputValue.trim()) {
e.preventDefault()
addEmail(inputValue)
}
if (e.key === 'Backspace' && !inputValue) {
if (invalidEmails.length > 0) {
if (duplicateEmails.length > 0) {
removeDuplicateEmail(duplicateEmails.length - 1)
} else if (invalidEmails.length > 0) {
removeInvalidEmail(invalidEmails.length - 1)
} else if (emails.length > 0) {
removeEmail(emails.length - 1)
}
}
},
[inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail]
[
inputValue,
addEmail,
duplicateEmails,
invalidEmails,
emails,
removeDuplicateEmail,
removeInvalidEmail,
removeEmail,
]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
const pastedEmails = extractEmailsFromText(pastedText)
let addedCount = 0
pastedEmails.forEach((email) => {
if (addEmail(email)) {
addedCount++
}
addEmail(email)
})
if (addedCount === 0 && pastedEmails.length === 1) {
setInputValue(inputValue + pastedEmails[0])
}
},
[addEmail, inputValue]
[addEmail, extractEmailsFromText]
)
const handleSubmit = useCallback(
@@ -518,7 +624,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
addEmail(inputValue)
}
// Clear messages at start of submission
setErrorMessage(null)
setSuccessMessage(null)
@@ -644,10 +749,11 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
)
const resetState = useCallback(() => {
// Batch state updates using React's automatic batching in React 18+
setInputValue('')
setEmails([])
setInvalidEmails([])
setDuplicateEmails([])
setIsDragging(false)
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
@@ -718,7 +824,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
tabIndex={-1}
readOnly
/>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] focus-within:outline-none'>
<input
ref={fileInputRef}
type='file'
accept='.csv,.txt,text/csv,text/plain'
onChange={handleFileInputChange}
className='hidden'
/>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'scrollbar-hide relative flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] transition-colors focus-within:outline-none',
isDragging && 'border-[var(--border)] border-dashed bg-[var(--surface-5)]'
)}
>
{isDragging && (
<div className='absolute inset-0 flex items-center justify-center rounded-[4px] bg-[var(--surface-5)]/90'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Drop file here
</span>
</div>
)}
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
@@ -728,6 +856,25 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
isInvalid={true}
/>
))}
{duplicateEmails.map((email, index) => (
<div
key={`duplicate-${index}`}
className='flex w-auto items-center gap-[4px] rounded-[4px] border border-amber-500 bg-amber-500/10 px-[6px] py-[2px] text-[12px] text-amber-600 dark:bg-amber-500/20 dark:text-amber-400'
>
<span className='max-w-[200px] truncate'>{email}</span>
<span className='text-[11px] opacity-70'>duplicate</span>
{!isSubmitting && userPerms.canAdmin && (
<button
type='button'
onClick={() => removeDuplicateEmail(index)}
className='flex-shrink-0 text-amber-600 transition-colors hover:text-amber-700 focus:outline-none dark:text-amber-400 dark:hover:text-amber-300'
aria-label={`Remove ${email}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
)}
</div>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
@@ -736,36 +883,52 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
disabled={isSubmitting || !userPerms.canAdmin}
/>
))}
<Input
id='invite-field'
name='invite_search_field'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
<div className='relative flex flex-1 items-center'>
<Input
id='invite-field'
name='invite_search_field'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 ||
invalidEmails.length > 0 ||
duplicateEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className={cn(
'h-6 min-w-[140px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 || duplicateEmails.length > 0
? 'pl-[4px]'
: 'pl-[4px]'
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
{userPerms.canAdmin && (
<button
type='button'
onClick={() => fileInputRef.current?.click()}
className='ml-[4px] flex-shrink-0 text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
disabled={isSubmitting}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
</button>
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
</div>
</div>
</div>
{errorMessage && (

View File

@@ -95,6 +95,7 @@ export type WebhookExecutionPayload = {
testMode?: boolean
executionTarget?: 'deployed' | 'live'
credentialId?: string
credentialAccountUserId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -241,6 +242,7 @@ async function executeWebhookJobInternal(
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -499,6 +501,7 @@ async function executeWebhookJobInternal(
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -508,7 +511,9 @@ async function executeWebhookJobInternal(
},
}
const snapshot = new ExecutionSnapshot(metadata, workflow, input || {}, workflowVariables, [])
const triggerInput = input || {}
const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
const executionResult = await executeWorkflowCore({
snapshot,

View File

@@ -254,6 +254,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

View File

@@ -1,3 +1,4 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
export { PollingGroupInvitationEmail } from './polling-group-invitation-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'

View File

@@ -0,0 +1,52 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
interface PollingGroupInvitationEmailProps {
inviterName?: string
organizationName?: string
pollingGroupName?: string
provider?: 'google-email' | 'outlook'
inviteLink?: string
}
export function PollingGroupInvitationEmail({
inviterName = 'A team member',
organizationName = 'an organization',
pollingGroupName = 'a polling group',
provider = 'google-email',
inviteLink = '',
}: PollingGroupInvitationEmailProps) {
const brand = getBrandConfig()
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
return (
<EmailLayout preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
join the polling group <strong>{pollingGroupName}</strong> on {brand.name}.
</Text>
<Text style={baseStyles.paragraph}>
By accepting this invitation, your {providerName} account will be connected to enable email
polling for automated workflows.
</Text>
<Link href={inviteLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
This invitation expires in 7 days. If you weren't expecting this email, you can safely
ignore it.
</Text>
</EmailLayout>
)
}
export default PollingGroupInvitationEmail

View File

@@ -12,6 +12,7 @@ import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/e
import {
BatchInvitationEmail,
InvitationEmail,
PollingGroupInvitationEmail,
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
@@ -184,6 +185,24 @@ export async function renderWorkspaceInvitationEmail(
)
}
export async function renderPollingGroupInvitationEmail(params: {
inviterName: string
organizationName: string
pollingGroupName: string
provider: 'google-email' | 'outlook'
inviteLink: string
}): Promise<string> {
return await render(
PollingGroupInvitationEmail({
inviterName: params.inviterName,
organizationName: params.organizationName,
pollingGroupName: params.pollingGroupName,
provider: params.provider,
inviteLink: params.inviteLink,
})
)
}
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number

View File

@@ -8,6 +8,7 @@ export type EmailSubjectType =
| 'reset-password'
| 'invitation'
| 'batch-invitation'
| 'polling-group-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
@@ -38,6 +39,8 @@ export function getEmailSubject(type: EmailSubjectType): string {
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
case 'polling-group-invitation':
return `You've been invited to join an email polling group on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':

View File

@@ -181,6 +181,22 @@ export const MCP = {
TOOL_PREFIX: 'mcp-',
} as const
export const CREDENTIAL_SET = {
PREFIX: 'credentialSet:',
} as const
export const CREDENTIAL = {
FOREIGN_LABEL: 'Saved by collaborator',
} 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,

View File

@@ -17,6 +17,7 @@ export interface ExecutionMetadata {
isClientSession?: boolean
pendingBlocks?: string[]
resumeFromSnapshot?: boolean
credentialAccountUserId?: string
workflowStateOverride?: {
blocks: Record<string, any>
edges: Edge[]

View File

@@ -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?: {

View File

@@ -0,0 +1,370 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'
export interface CredentialSet {
id: string
name: string
description: string | null
providerId: 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
providerId: string | null
organizationId: string
organizationName: string
}
export interface CredentialSetInvitation {
invitationId: string
token: string
status: string
expiresAt: string
createdAt: string
credentialSetId: string
credentialSetName: string
providerId: string | null
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<CredentialSet[]> {
if (!organizationId) return []
const data = await fetchJson<CredentialSetsResponse>('/api/credential-sets', {
searchParams: { organizationId },
})
return data.credentialSets ?? []
}
export function useCredentialSets(organizationId?: string, enabled = true) {
return useQuery<CredentialSet[]>({
queryKey: credentialSetKeys.list(organizationId),
queryFn: () => fetchCredentialSets(organizationId ?? ''),
enabled: Boolean(organizationId) && enabled,
staleTime: 60 * 1000,
})
}
interface CredentialSetDetailResponse {
credentialSet?: CredentialSet
}
export async function fetchCredentialSetById(id: string): Promise<CredentialSet | null> {
if (!id) return null
const data = await fetchJson<CredentialSetDetailResponse>(`/api/credential-sets/${id}`)
return data.credentialSet ?? null
}
export function useCredentialSetDetail(id?: string, enabled = true) {
return useQuery<CredentialSet | null>({
queryKey: credentialSetKeys.detail(id),
queryFn: () => fetchCredentialSetById(id ?? ''),
enabled: Boolean(id) && enabled,
staleTime: 60 * 1000,
})
}
export function useCredentialSetMemberships() {
return useQuery<CredentialSetMembership[]>({
queryKey: credentialSetKeys.memberships(),
queryFn: async () => {
const data = await fetchJson<MembershipsResponse>('/api/credential-sets/memberships')
return data.memberships ?? []
},
staleTime: 60 * 1000,
})
}
export function useCredentialSetInvitations() {
return useQuery<CredentialSetInvitation[]>({
queryKey: credentialSetKeys.invitations(),
queryFn: async () => {
const data = await fetchJson<InvitationsResponse>('/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 interface CreateCredentialSetData {
organizationId: string
name: string
description?: string
providerId?: string
}
export function useCreateCredentialSet() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreateCredentialSetData) => {
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 })
},
})
}
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<CredentialSetMember[]>({
queryKey: [...credentialSetKeys.detail(credentialSetId), 'members'],
queryFn: async () => {
const data = await fetchJson<MembersResponse>(
`/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 })
},
})
}
export function useLeaveCredentialSet() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (credentialSetId: string) => {
const response = await fetch(
`/api/credential-sets/memberships?credentialSetId=${credentialSetId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to leave credential set')
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
},
})
}
export interface DeleteCredentialSetParams {
credentialSetId: string
organizationId: string
}
export function useDeleteCredentialSet() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ credentialSetId }: DeleteCredentialSetParams) => {
const response = await fetch(`/api/credential-sets/${credentialSetId}`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to delete credential set')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: credentialSetKeys.list(variables.organizationId),
})
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
},
})
}
export interface CredentialSetInvitationDetail {
id: string
credentialSetId: string
email: string | null
token: string
status: string
expiresAt: string
createdAt: string
invitedBy: string
}
interface InvitationsDetailResponse {
invitations?: CredentialSetInvitationDetail[]
}
export function useCredentialSetInvitationsDetail(credentialSetId?: string) {
return useQuery<CredentialSetInvitationDetail[]>({
queryKey: [...credentialSetKeys.detail(credentialSetId), 'invitations'],
queryFn: async () => {
const data = await fetchJson<InvitationsDetailResponse>(
`/api/credential-sets/${credentialSetId}/invite`
)
return (data.invitations ?? []).filter((inv) => inv.status === 'pending')
},
enabled: Boolean(credentialSetId),
staleTime: 30 * 1000,
})
}
export function useCancelCredentialSetInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: { credentialSetId: string; invitationId: string }) => {
const response = await fetch(
`/api/credential-sets/${data.credentialSetId}/invite?invitationId=${data.invitationId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to cancel invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'],
})
},
})
}
export function useResendCredentialSetInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: { credentialSetId: string; invitationId: string; email: string }) => {
const response = await fetch(
`/api/credential-sets/${data.credentialSetId}/invite/${data.invitationId}`,
{ method: 'POST' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to resend invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'],
})
},
})
}

View File

@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers'
interface CredentialListResponse {
@@ -61,14 +63,28 @@ 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 by ID directly
const { data: credentialSetData, isFetching: credentialSetLoading } = useCredentialSetDetail(
credentialSetId,
isCredentialSet
)
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,
@@ -77,12 +93,17 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeignCredentialSet = isCredentialSet && !credentialSetData && !credentialSetLoading
const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null)
const displayName =
credentialSetData?.name ??
selectedCredential?.name ??
(hasForeignMeta ? CREDENTIAL.FOREIGN_LABEL : null) ??
(isForeignCredentialSet ? CREDENTIAL.FOREIGN_LABEL : null)
return {
displayName,
isLoading: credentialsLoading || foreignLoading,
isLoading: credentialsLoading || foreignLoading || (isCredentialSet && credentialSetLoading),
hasForeignMeta,
}
}

View File

@@ -363,6 +363,32 @@ export function useCancelInvitation() {
})
}
/**
* Resend invitation mutation
*/
interface ResendInvitationParams {
invitationId: string
orgId: string
}
export function useResendInvitation() {
return useMutation({
mutationFn: async ({ invitationId, orgId }: ResendInvitationParams) => {
const response = await fetch(`/api/organizations/${orgId}/invitations/${invitationId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to resend invitation')
}
return response.json()
},
})
}
/**
* Update seats mutation (handles both add and reduce)
*/

View File

@@ -10,6 +10,8 @@ import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('useWebhookManagement')
const CREDENTIAL_SET_PREFIX = 'credentialSet:'
interface UseWebhookManagementProps {
blockId: string
triggerId?: string
@@ -169,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<string, unknown>
useSubBlockStore.getState().setValue(blockId, 'triggerConfig', userConfigurableFields)
if (effectiveTriggerId) {
populateTriggerFieldsFromConfig(blockId, webhook.providerConfig, effectiveTriggerId)
@@ -220,9 +237,17 @@ export function useWebhookManagement({
}
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
const isCredentialSet = 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 +304,20 @@ export function useWebhookManagement({
): Promise<boolean> => {
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
const isCredentialSet = 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,
},
}),

View File

@@ -55,6 +55,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
@@ -188,6 +189,49 @@ export const auth = betterAuth({
})
.where(eq(schema.account.id, existing.id))
// Sync webhooks for credential sets after reconnecting
const requestId = crypto.randomUUID().slice(0, 8)
const userMemberships = await db
.select({
credentialSetId: schema.credentialSetMember.credentialSetId,
providerId: schema.credentialSet.providerId,
})
.from(schema.credentialSetMember)
.innerJoin(
schema.credentialSet,
eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
)
.where(
and(
eq(schema.credentialSetMember.userId, account.userId),
eq(schema.credentialSetMember.status, 'active')
)
)
for (const membership of userMemberships) {
if (membership.providerId === account.providerId) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info(
'[account.create.before] Synced webhooks after credential reconnect',
{
credentialSetId: membership.credentialSetId,
providerId: account.providerId,
}
)
} catch (error) {
logger.error(
'[account.create.before] Failed to sync webhooks after credential reconnect',
{
credentialSetId: membership.credentialSetId,
providerId: account.providerId,
error,
}
)
}
}
}
return false
}
@@ -235,6 +279,46 @@ export const auth = betterAuth({
await db.update(schema.account).set(updates).where(eq(schema.account.id, account.id))
}
}
// Sync webhooks for credential sets after connecting a new credential
const requestId = crypto.randomUUID().slice(0, 8)
const userMemberships = await db
.select({
credentialSetId: schema.credentialSetMember.credentialSetId,
providerId: schema.credentialSet.providerId,
})
.from(schema.credentialSetMember)
.innerJoin(
schema.credentialSet,
eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
)
.where(
and(
eq(schema.credentialSetMember.userId, account.userId),
eq(schema.credentialSetMember.status, 'active')
)
)
for (const membership of userMemberships) {
if (membership.providerId === account.providerId) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info('[account.create.after] Synced webhooks after credential connect', {
credentialSetId: membership.credentialSetId,
providerId: account.providerId,
})
} catch (error) {
logger.error(
'[account.create.after] Failed to sync webhooks after credential connect',
{
credentialSetId: membership.credentialSetId,
providerId: account.providerId,
error,
}
)
}
}
}
},
},
},

View File

@@ -0,0 +1,27 @@
export type PollingProvider = 'google-email' | 'outlook'
export const POLLING_PROVIDERS: Record<PollingProvider, { displayName: string }> = {
'google-email': { displayName: 'Gmail' },
outlook: { displayName: 'Outlook' },
}
export function getProviderDisplayName(providerId: string): string {
if (providerId === 'google-email') return 'Gmail'
if (providerId === 'outlook') return 'Outlook'
return providerId
}
export function isPollingProvider(provider: string): provider is PollingProvider {
return provider === 'google-email' || provider === 'outlook'
}
/**
* Maps an OAuth provider ID to its corresponding polling provider ID.
* Since credential sets now store the OAuth provider ID directly, this is primarily
* used in the credential selector to match OAuth providers to credential sets.
*/
export function getPollingProviderFromOAuth(oauthProviderId: string): PollingProvider | null {
if (oauthProviderId === 'google-email') return 'google-email'
if (oauthProviderId === 'outlook') return 'outlook'
return null
}

View File

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

View File

@@ -359,7 +359,19 @@ async function fetchNewOutlookEmails(
const data = await response.json()
const emails = data.value || []
const filteredEmails = filterEmailsByFolder(emails, config)
let resolvedFolderIds: Map<string, string> | undefined
if (config.folderIds && config.folderIds.length > 0) {
const hasWellKnownFolders = config.folderIds.some(isWellKnownFolderName)
if (hasWellKnownFolders) {
resolvedFolderIds = await resolveWellKnownFolderIds(
accessToken,
config.folderIds,
requestId
)
}
}
const filteredEmails = filterEmailsByFolder(emails, config, resolvedFolderIds)
logger.info(
`[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering`
@@ -373,18 +385,103 @@ async function fetchNewOutlookEmails(
}
}
const OUTLOOK_WELL_KNOWN_FOLDERS = new Set([
'inbox',
'drafts',
'sentitems',
'deleteditems',
'junkemail',
'archive',
'outbox',
])
function isWellKnownFolderName(folderId: string): boolean {
return OUTLOOK_WELL_KNOWN_FOLDERS.has(folderId.toLowerCase())
}
async function resolveWellKnownFolderId(
accessToken: string,
folderName: string,
requestId: string
): Promise<string | null> {
try {
const response = await fetch(`https://graph.microsoft.com/v1.0/me/mailFolders/${folderName}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
logger.warn(
`[${requestId}] Failed to resolve well-known folder '${folderName}': ${response.status}`
)
return null
}
const folder = await response.json()
return folder.id || null
} catch (error) {
logger.error(`[${requestId}] Error resolving well-known folder '${folderName}':`, error)
return null
}
}
async function resolveWellKnownFolderIds(
accessToken: string,
folderIds: string[],
requestId: string
): Promise<Map<string, string>> {
const resolvedIds = new Map<string, string>()
const wellKnownFolders = folderIds.filter(isWellKnownFolderName)
if (wellKnownFolders.length === 0) {
return resolvedIds
}
const resolutions = await Promise.all(
wellKnownFolders.map(async (folderName) => {
const actualId = await resolveWellKnownFolderId(accessToken, folderName, requestId)
return { folderName, actualId }
})
)
for (const { folderName, actualId } of resolutions) {
if (actualId) {
resolvedIds.set(folderName.toLowerCase(), actualId)
}
}
logger.info(
`[${requestId}] Resolved ${resolvedIds.size}/${wellKnownFolders.length} well-known folders`
)
return resolvedIds
}
function filterEmailsByFolder(
emails: OutlookEmail[],
config: OutlookWebhookConfig
config: OutlookWebhookConfig,
resolvedFolderIds?: Map<string, string>
): OutlookEmail[] {
if (!config.folderIds || !config.folderIds.length) {
return emails
}
const actualFolderIds = config.folderIds.map((configFolder) => {
if (resolvedFolderIds && isWellKnownFolderName(configFolder)) {
const resolvedId = resolvedFolderIds.get(configFolder.toLowerCase())
if (resolvedId) {
return resolvedId
}
}
return configFolder
})
return emails.filter((email) => {
const emailFolderId = email.parentFolderId
const hasMatchingFolder = config.folderIds!.some((configFolder) =>
emailFolderId.toLowerCase().includes(configFolder.toLowerCase())
const hasMatchingFolder = actualFolderIds.some(
(folderId) => emailFolderId.toLowerCase() === folderId.toLowerCase()
)
return config.folderFilterBehavior === 'INCLUDE' ? hasMatchingFolder : !hasMatchingFolder

View File

@@ -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 {
@@ -40,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
@@ -109,6 +153,17 @@ export async function handleProviderChallenges(
}
const url = new URL(request.url)
// Microsoft Graph subscription validation (can come as GET or POST)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
@@ -149,6 +204,61 @@ export function handleProviderReachabilityTest(
return null
}
/**
* Format error response based on provider requirements.
* Some providers (like Microsoft Teams) require specific response formats.
*/
export function formatProviderErrorResponse(
webhook: any,
error: string,
status: number
): NextResponse {
if (webhook.provider === 'microsoft-teams') {
return NextResponse.json({ type: 'message', text: error }, { status })
}
return NextResponse.json({ error }, { status })
}
/**
* Check if a webhook event should be skipped based on provider-specific filtering.
* Returns true if the event should be skipped, false if it should be processed.
*/
export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean {
const providerConfig = (webhook.providerConfig as Record<string, any>) || {}
if (webhook.provider === 'stripe') {
const eventTypes = providerConfig.eventTypes
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 for webhook ${webhook.id}, skipping`
)
return true
}
}
}
return false
}
/** Providers that validate webhook URLs during creation, before workflow deployment */
const PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION = new Set(['grain'])
/** Returns 200 OK for providers that validate URLs before the workflow is deployed */
export function handlePreDeploymentVerification(
webhook: any,
requestId: string
): NextResponse | null {
if (PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION.has(webhook.provider)) {
logger.info(
`[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation`
)
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
}
return null
}
export async function findWebhookAndWorkflow(
options: WebhookProcessorOptions
): Promise<{ webhook: any; workflow: any } | null> {
@@ -193,6 +303,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<Array<{ webhook: any; workflow: any }>> {
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
@@ -774,9 +915,22 @@ export async function queueWebhookExecution(
}
}
// Extract credentialId from webhook config for credential-based webhooks
// 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<string, any>) || {}
const credentialId = providerConfig.credentialId as string | undefined
const credentialSetId = providerConfig.credentialSetId as string | undefined
// 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,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import type { DbOrTx } from '@/lib/db/types'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookUtils')
@@ -2477,7 +2478,314 @@ 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<string, any>
requestId: string
tx?: DbOrTx
}): Promise<CredentialSetWebhookSyncResult> {
const {
workflowId,
blockId,
provider,
basePath,
credentialSetId,
oauthProviderId,
providerConfig,
requestId,
tx,
} = params
const dbCtx = tx ?? db
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 dbCtx
.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<string, any>
return config?.credentialSetId === credentialSetId
})
syncLogger.info(
`[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set`
)
// Build maps for efficient lookup
const existingByCredentialId = new Map<string, (typeof credentialSetWebhooks)[number]>()
for (const wh of credentialSetWebhooks) {
const config = wh.providerConfig as Record<string, any>
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<string, any>
const updatedConfig = {
...providerConfig,
basePath, // Store basePath for reliable reconstruction during membership sync
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 dbCtx
.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,
basePath, // Store basePath for reliable reconstruction during membership sync
credentialId: cred.credentialId,
credentialSetId: credentialSetId,
userId: cred.userId,
}
await dbCtx.insert(webhook).values({
id: webhookId,
workflowId,
blockId,
path: webhookPath,
provider,
providerConfig: newConfig,
credentialSetId, // Indexed column for efficient credential set queries
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 dbCtx.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,
tx?: DbOrTx
): Promise<{ workflowsUpdated: number; totalCreated: number; totalDeleted: number }> {
const dbCtx = tx ?? db
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 using the indexed column
const webhooksForSet = await dbCtx
.select()
.from(webhook)
.where(eq(webhook.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<string, (typeof webhooksForSet)[number]>()
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<string, any>
const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider)
const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config
// Use stored basePath if available, otherwise fall back to blockId (for legacy webhooks)
const basePath = config.basePath || representativeWebhook.blockId || representativeWebhook.path
try {
const result = await syncWebhooksForCredentialSet({
workflowId: representativeWebhook.workflowId,
blockId: representativeWebhook.blockId || '',
provider: representativeWebhook.provider,
basePath,
credentialSetId,
oauthProviderId,
providerConfig: baseConfig,
requestId,
tx: dbCtx,
})
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<boolean> {
const logger = createLogger('GmailWebhookSetup')
@@ -2492,7 +2800,7 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
return false
}
// Get userId from credential
// 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(
@@ -2502,6 +2810,8 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
}
const effectiveUserId = rows[0].userId
// Verify token can be refreshed
const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
@@ -2528,14 +2838,14 @@ export async function configureGmailPolling(webhookData: any, requestId: string)
providerConfig: {
...providerConfig,
userId: effectiveUserId,
...(credentialId ? { credentialId } : {}),
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,
@@ -2557,7 +2867,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,
@@ -2575,7 +2886,7 @@ export async function configureOutlookPolling(
return false
}
// Get userId from credential
// 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(
@@ -2585,6 +2896,8 @@ export async function configureOutlookPolling(
}
const effectiveUserId = rows[0].userId
// Verify token can be refreshed
const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
@@ -2593,30 +2906,28 @@ export async function configureOutlookPolling(
return false
}
const providerCfg = (webhookData.providerConfig as Record<string, any>) || {}
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerCfg,
...providerConfig,
userId: effectiveUserId,
...(credentialId ? { credentialId } : {}),
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,

View File

@@ -430,6 +430,7 @@ export async function saveWorkflowToNormalizedTables(
path: wh.path,
provider: wh.provider,
providerConfig: wh.providerConfig,
credentialSetId: wh.credentialSetId,
isActive: wh.isActive,
createdAt: wh.createdAt,
updatedAt: new Date(),

View File

@@ -253,9 +253,8 @@ export async function executeTool(
try {
const baseUrl = getBaseUrl()
// Prepare the token payload
const tokenPayload: OAuthTokenPayload = {
credentialId: contextParams.credential,
credentialId: contextParams.credential as string,
}
// Add workflowId if it exists in params, context, or executionContext

View File

@@ -122,7 +122,9 @@ export interface TableRow {
}
export interface OAuthTokenPayload {
credentialId: string
credentialId?: string
credentialAccountUserId?: string
providerId?: string
workflowId?: string
}

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",

View File

@@ -0,0 +1,61 @@
CREATE TYPE "public"."credential_set_invitation_status" AS ENUM('pending', 'accepted', 'expired', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."credential_set_member_status" AS ENUM('active', 'pending', 'revoked');--> statement-breakpoint
CREATE TABLE "credential_set" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"provider_id" text NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "credential_set_invitation" (
"id" text PRIMARY KEY NOT NULL,
"credential_set_id" text NOT NULL,
"email" text,
"token" text NOT NULL,
"invited_by" text NOT NULL,
"status" "credential_set_invitation_status" DEFAULT 'pending' NOT NULL,
"expires_at" timestamp NOT NULL,
"accepted_at" timestamp,
"accepted_by_user_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "credential_set_invitation_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "credential_set_member" (
"id" text PRIMARY KEY NOT NULL,
"credential_set_id" text NOT NULL,
"user_id" text NOT NULL,
"status" "credential_set_member_status" DEFAULT 'pending' NOT NULL,
"joined_at" timestamp,
"invited_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "webhook" ADD COLUMN "credential_set_id" text;--> statement-breakpoint
ALTER TABLE "credential_set" ADD CONSTRAINT "credential_set_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set" ADD CONSTRAINT "credential_set_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_accepted_by_user_id_user_id_fk" FOREIGN KEY ("accepted_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "credential_set_organization_id_idx" ON "credential_set" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "credential_set_created_by_idx" ON "credential_set" USING btree ("created_by");--> statement-breakpoint
CREATE UNIQUE INDEX "credential_set_org_name_unique" ON "credential_set" USING btree ("organization_id","name");--> statement-breakpoint
CREATE INDEX "credential_set_provider_id_idx" ON "credential_set" USING btree ("provider_id");--> statement-breakpoint
CREATE INDEX "credential_set_invitation_set_id_idx" ON "credential_set_invitation" USING btree ("credential_set_id");--> statement-breakpoint
CREATE INDEX "credential_set_invitation_token_idx" ON "credential_set_invitation" USING btree ("token");--> statement-breakpoint
CREATE INDEX "credential_set_invitation_status_idx" ON "credential_set_invitation" USING btree ("status");--> statement-breakpoint
CREATE INDEX "credential_set_invitation_expires_at_idx" ON "credential_set_invitation" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "credential_set_member_set_id_idx" ON "credential_set_member" USING btree ("credential_set_id");--> statement-breakpoint
CREATE INDEX "credential_set_member_user_id_idx" ON "credential_set_member" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "credential_set_member_unique" ON "credential_set_member" USING btree ("credential_set_id","user_id");--> statement-breakpoint
CREATE INDEX "credential_set_member_status_idx" ON "credential_set_member" USING btree ("status");--> statement-breakpoint
ALTER TABLE "webhook" ADD CONSTRAINT "webhook_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "webhook_credential_set_id_idx" ON "webhook" USING btree ("credential_set_id");

File diff suppressed because it is too large Load Diff

View File

@@ -939,6 +939,13 @@
"when": 1766779827389,
"tag": "0134_parallel_galactus",
"breakpoints": true
},
{
"idx": 135,
"version": "7",
"when": 1767737974016,
"tag": "0135_stormy_puff_adder",
"breakpoints": true
}
]
}

View File

@@ -527,6 +527,9 @@ export const webhook = pgTable(
isActive: boolean('is_active').notNull().default(true),
failedCount: integer('failed_count').default(0), // Track consecutive failures
lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed
credentialSetId: text('credential_set_id').references(() => credentialSet.id, {
onDelete: 'set null',
}), // For credential set webhooks - enables efficient queries
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
@@ -539,6 +542,8 @@ export const webhook = pgTable(
table.workflowId,
table.blockId
),
// Optimize queries for credential set webhooks
credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId),
}
}
)
@@ -1777,3 +1782,98 @@ 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'),
providerId: text('provider_id').notNull(),
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
),
providerIdIdx: index('credential_set_provider_id_idx').on(table.providerId),
})
)
export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [
'active',
'pending',
'revoked',
])
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),
})
)