Skip to content

Commit 2d5cc9b

Browse files
committed
progress
1 parent 1db3e33 commit 2d5cc9b

File tree

27 files changed

+730
-341
lines changed

27 files changed

+730
-341
lines changed

apps/sim/app/api/auth/oauth/disconnect/route.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
2+
import { account, credentialSet, credentialSetMember } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, like, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
910

1011
export const dynamic = 'force-dynamic'
1112

@@ -74,6 +75,50 @@ export async function POST(request: NextRequest) {
7475
)
7576
}
7677

78+
// Sync webhooks for all credential sets the user is a member of
79+
// This removes webhooks that were using the disconnected credential
80+
const userMemberships = await db
81+
.select({
82+
id: credentialSetMember.id,
83+
credentialSetId: credentialSetMember.credentialSetId,
84+
providerId: credentialSet.providerId,
85+
type: credentialSet.type,
86+
})
87+
.from(credentialSetMember)
88+
.innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
89+
.where(
90+
and(
91+
eq(credentialSetMember.userId, session.user.id),
92+
eq(credentialSetMember.status, 'active')
93+
)
94+
)
95+
96+
for (const membership of userMemberships) {
97+
// Only sync if the credential set matches this provider or is 'all' type
98+
const matchesProvider =
99+
membership.type === 'all' ||
100+
membership.providerId === provider ||
101+
membership.providerId === providerId ||
102+
(membership.providerId && provider.startsWith(membership.providerId))
103+
104+
if (matchesProvider) {
105+
try {
106+
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
107+
logger.info(`[${requestId}] Synced webhooks after credential disconnect`, {
108+
credentialSetId: membership.credentialSetId,
109+
provider,
110+
})
111+
} catch (error) {
112+
// Log but don't fail the disconnect - credential is already removed
113+
logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, {
114+
credentialSetId: membership.credentialSetId,
115+
provider,
116+
error,
117+
})
118+
}
119+
}
120+
}
121+
77122
return NextResponse.json({ success: true }, { status: 200 })
78123
} catch (error) {
79124
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)

apps/sim/app/api/credential-sets/[id]/invite/route.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { db } from '@sim/db'
2-
import { credentialSet, credentialSetInvitation, member } from '@sim/db/schema'
2+
import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
7+
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
78
import { getSession } from '@/lib/auth'
9+
import { getBaseUrl } from '@/lib/core/utils/urls'
10+
import { sendEmail } from '@/lib/messaging/email/mailer'
811

912
const logger = createLogger('CredentialSetInvite')
1013

@@ -18,6 +21,7 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
1821
id: credentialSet.id,
1922
organizationId: credentialSet.organizationId,
2023
name: credentialSet.name,
24+
providerId: credentialSet.providerId,
2125
})
2226
.from(credentialSet)
2327
.where(eq(credentialSet.id, credentialSetId))
@@ -98,12 +102,58 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
98102

99103
await db.insert(credentialSetInvitation).values(invitation)
100104

101-
const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/credential-account/${token}`
105+
const inviteUrl = `${getBaseUrl()}/credential-account/${token}`
106+
107+
// Send email if email address was provided
108+
if (email) {
109+
try {
110+
// Get inviter name
111+
const [inviter] = await db
112+
.select({ name: user.name })
113+
.from(user)
114+
.where(eq(user.id, session.user.id))
115+
.limit(1)
116+
117+
// Get organization name
118+
const [org] = await db
119+
.select({ name: organization.name })
120+
.from(organization)
121+
.where(eq(organization.id, result.set.organizationId))
122+
.limit(1)
123+
124+
const provider = (result.set.providerId as 'gmail' | 'outlook') || 'gmail'
125+
const emailHtml = await renderPollingGroupInvitationEmail({
126+
inviterName: inviter?.name || 'A team member',
127+
organizationName: org?.name || 'your organization',
128+
pollingGroupName: result.set.name,
129+
provider,
130+
inviteLink: inviteUrl,
131+
})
132+
133+
const emailResult = await sendEmail({
134+
to: email,
135+
subject: getEmailSubject('polling-group-invitation'),
136+
html: emailHtml,
137+
emailType: 'transactional',
138+
})
139+
140+
if (!emailResult.success) {
141+
logger.warn('Failed to send invitation email', {
142+
email,
143+
error: emailResult.message,
144+
})
145+
}
146+
} catch (emailError) {
147+
logger.error('Error sending invitation email', emailError)
148+
// Don't fail the invitation creation if email fails
149+
}
150+
}
102151

103152
logger.info('Created credential set invitation', {
104153
credentialSetId: id,
105154
invitationId: invitation.id,
106155
userId: session.user.id,
156+
emailSent: !!email,
107157
})
108158

109159
return NextResponse.json({

apps/sim/app/api/credential-sets/[id]/members/route.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,24 +152,24 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
152152
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
153153
}
154154

155-
await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
155+
const requestId = crypto.randomUUID().slice(0, 8)
156156

157-
logger.info('Removed member from credential set', {
158-
credentialSetId: id,
159-
memberId,
160-
userId: session.user.id,
161-
})
157+
// Use transaction to ensure member deletion + webhook sync are atomic
158+
await db.transaction(async (tx) => {
159+
await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
162160

163-
try {
164-
const requestId = crypto.randomUUID().slice(0, 8)
165-
const syncResult = await syncAllWebhooksForCredentialSet(id, requestId)
161+
const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx)
166162
logger.info('Synced webhooks after member removed', {
167163
credentialSetId: id,
168164
...syncResult,
169165
})
170-
} catch (syncError) {
171-
logger.error('Error syncing webhooks after member removed', syncError)
172-
}
166+
})
167+
168+
logger.info('Removed member from credential set', {
169+
credentialSetId: id,
170+
memberId,
171+
userId: session.user.id,
172+
})
173173

174174
return NextResponse.json({ success: true })
175175
} catch (error) {

apps/sim/app/api/credential-sets/invite/[token]/route.ts

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
9191
return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
9292
}
9393

94-
if (invitation.email && invitation.email !== session.user.email) {
95-
return NextResponse.json({ error: 'Email does not match invitation' }, { status: 400 })
96-
}
97-
9894
const existingMember = await db
9995
.select()
10096
.from(credentialSetMember)
@@ -114,64 +110,66 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
114110
}
115111

116112
const now = new Date()
117-
await db.insert(credentialSetMember).values({
118-
id: crypto.randomUUID(),
119-
credentialSetId: invitation.credentialSetId,
120-
userId: session.user.id,
121-
status: 'active',
122-
joinedAt: now,
123-
invitedBy: invitation.invitedBy,
124-
createdAt: now,
125-
updatedAt: now,
126-
})
113+
const requestId = crypto.randomUUID().slice(0, 8)
127114

128-
await db
129-
.update(credentialSetInvitation)
130-
.set({
131-
status: 'accepted',
132-
acceptedAt: now,
133-
acceptedByUserId: session.user.id,
115+
// Use transaction to ensure membership + invitation update + webhook sync are atomic
116+
await db.transaction(async (tx) => {
117+
await tx.insert(credentialSetMember).values({
118+
id: crypto.randomUUID(),
119+
credentialSetId: invitation.credentialSetId,
120+
userId: session.user.id,
121+
status: 'active',
122+
joinedAt: now,
123+
invitedBy: invitation.invitedBy,
124+
createdAt: now,
125+
updatedAt: now,
134126
})
135-
.where(eq(credentialSetInvitation.id, invitation.id))
136127

137-
// Clean up all other pending invitations for the same credential set and email
138-
// This prevents duplicate invites from showing up after accepting one
139-
if (invitation.email) {
140-
await db
128+
await tx
141129
.update(credentialSetInvitation)
142130
.set({
143131
status: 'accepted',
144132
acceptedAt: now,
145133
acceptedByUserId: session.user.id,
146134
})
147-
.where(
148-
and(
149-
eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId),
150-
eq(credentialSetInvitation.email, invitation.email),
151-
eq(credentialSetInvitation.status, 'pending')
152-
)
153-
)
154-
}
135+
.where(eq(credentialSetInvitation.id, invitation.id))
155136

156-
logger.info('Accepted credential set invitation', {
157-
invitationId: invitation.id,
158-
credentialSetId: invitation.credentialSetId,
159-
userId: session.user.id,
160-
})
137+
// Clean up all other pending invitations for the same credential set and email
138+
// This prevents duplicate invites from showing up after accepting one
139+
if (invitation.email) {
140+
await tx
141+
.update(credentialSetInvitation)
142+
.set({
143+
status: 'accepted',
144+
acceptedAt: now,
145+
acceptedByUserId: session.user.id,
146+
})
147+
.where(
148+
and(
149+
eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId),
150+
eq(credentialSetInvitation.email, invitation.email),
151+
eq(credentialSetInvitation.status, 'pending')
152+
)
153+
)
154+
}
161155

162-
try {
163-
const requestId = crypto.randomUUID().slice(0, 8)
156+
// Sync webhooks within the transaction
164157
const syncResult = await syncAllWebhooksForCredentialSet(
165158
invitation.credentialSetId,
166-
requestId
159+
requestId,
160+
tx
167161
)
168162
logger.info('Synced webhooks after member joined', {
169163
credentialSetId: invitation.credentialSetId,
170164
...syncResult,
171165
})
172-
} catch (syncError) {
173-
logger.error('Error syncing webhooks after member joined', syncError)
174-
}
166+
})
167+
168+
logger.info('Accepted credential set invitation', {
169+
invitationId: invitation.id,
170+
credentialSetId: invitation.credentialSetId,
171+
userId: session.user.id,
172+
})
175173

176174
return NextResponse.json({
177175
success: true,

apps/sim/app/api/credential-sets/memberships/route.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { db } from '@sim/db'
22
import { credentialSet, credentialSetMember, organization } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { eq } from 'drizzle-orm'
5-
import { NextResponse } from 'next/server'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
7+
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
78

89
const logger = createLogger('CredentialSetMemberships')
910

@@ -37,3 +38,77 @@ export async function GET() {
3738
return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 })
3839
}
3940
}
41+
42+
/**
43+
* Leave a credential set (self-revocation).
44+
* Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up.
45+
*/
46+
export async function DELETE(req: NextRequest) {
47+
const session = await getSession()
48+
49+
if (!session?.user?.id) {
50+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
51+
}
52+
53+
const { searchParams } = new URL(req.url)
54+
const credentialSetId = searchParams.get('credentialSetId')
55+
56+
if (!credentialSetId) {
57+
return NextResponse.json({ error: 'credentialSetId is required' }, { status: 400 })
58+
}
59+
60+
try {
61+
const requestId = crypto.randomUUID().slice(0, 8)
62+
63+
// Use transaction to ensure revocation + webhook sync are atomic
64+
await db.transaction(async (tx) => {
65+
// Find and verify membership
66+
const [membership] = await tx
67+
.select()
68+
.from(credentialSetMember)
69+
.where(
70+
and(
71+
eq(credentialSetMember.credentialSetId, credentialSetId),
72+
eq(credentialSetMember.userId, session.user.id)
73+
)
74+
)
75+
.limit(1)
76+
77+
if (!membership) {
78+
throw new Error('Not a member of this credential set')
79+
}
80+
81+
if (membership.status === 'revoked') {
82+
throw new Error('Already left this credential set')
83+
}
84+
85+
// Set status to 'revoked' - this immediately blocks credential from being used
86+
await tx
87+
.update(credentialSetMember)
88+
.set({
89+
status: 'revoked',
90+
updatedAt: new Date(),
91+
})
92+
.where(eq(credentialSetMember.id, membership.id))
93+
94+
// Sync webhooks to remove this user's credential webhooks
95+
const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId, tx)
96+
logger.info('Synced webhooks after member left', {
97+
credentialSetId,
98+
userId: session.user.id,
99+
...syncResult,
100+
})
101+
})
102+
103+
logger.info('User left credential set', {
104+
credentialSetId,
105+
userId: session.user.id,
106+
})
107+
108+
return NextResponse.json({ success: true })
109+
} catch (error) {
110+
const message = error instanceof Error ? error.message : 'Failed to leave credential set'
111+
logger.error('Error leaving credential set', error)
112+
return NextResponse.json({ error: message }, { status: 500 })
113+
}
114+
}

0 commit comments

Comments
 (0)