Skip to content

Commit 223d1e8

Browse files
committed
invite flow
1 parent 2d5cc9b commit 223d1e8

File tree

4 files changed

+171
-26
lines changed

4 files changed

+171
-26
lines changed

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,15 @@ function SignupFormContent({
109109
setEmail(emailParam)
110110
}
111111

112-
const redirectParam = searchParams.get('redirect')
112+
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
113+
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
113114
if (redirectParam) {
114115
setRedirectUrl(redirectParam)
115116

116-
if (redirectParam.startsWith('/invite/')) {
117+
if (
118+
redirectParam.startsWith('/invite/') ||
119+
redirectParam.startsWith('/credential-account/')
120+
) {
117121
setIsInviteFlow(true)
118122
}
119123
}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ toke
2424
status: credentialSetInvitation.status,
2525
expiresAt: credentialSetInvitation.expiresAt,
2626
credentialSetName: credentialSet.name,
27+
providerId: credentialSet.providerId,
2728
organizationId: credentialSet.organizationId,
2829
organizationName: organization.name,
2930
})
@@ -54,6 +55,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ toke
5455
invitation: {
5556
credentialSetName: invitation.credentialSetName,
5657
organizationName: invitation.organizationName,
58+
providerId: invitation.providerId,
5759
email: invitation.email,
5860
},
5961
})
@@ -68,16 +70,27 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
6870
}
6971

7072
try {
71-
const [invitation] = await db
72-
.select()
73+
const [invitationData] = await db
74+
.select({
75+
id: credentialSetInvitation.id,
76+
credentialSetId: credentialSetInvitation.credentialSetId,
77+
email: credentialSetInvitation.email,
78+
status: credentialSetInvitation.status,
79+
expiresAt: credentialSetInvitation.expiresAt,
80+
invitedBy: credentialSetInvitation.invitedBy,
81+
providerId: credentialSet.providerId,
82+
})
7383
.from(credentialSetInvitation)
84+
.innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
7485
.where(eq(credentialSetInvitation.token, token))
7586
.limit(1)
7687

77-
if (!invitation) {
88+
if (!invitationData) {
7889
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
7990
}
8091

92+
const invitation = invitationData
93+
8194
if (invitation.status !== 'pending') {
8295
return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 })
8396
}
@@ -174,6 +187,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
174187
return NextResponse.json({
175188
success: true,
176189
credentialSetId: invitation.credentialSetId,
190+
providerId: invitation.providerId,
177191
})
178192
} catch (error) {
179193
logger.error('Error accepting invitation', error)

apps/sim/app/credential-account/[token]/page.tsx

Lines changed: 126 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
'use client'
22

33
import { useCallback, useEffect, useState } from 'react'
4-
import { AlertCircle, CheckCircle2, Loader2, Shield } from 'lucide-react'
4+
import { AlertCircle, CheckCircle2, Loader2, Mail } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
66
import { Button } from '@/components/emcn'
7-
import { useSession } from '@/lib/auth/auth-client'
7+
import { GmailIcon, OutlookIcon } from '@/components/icons'
8+
import { client, useSession } from '@/lib/auth/auth-client'
89

910
interface InvitationInfo {
1011
credentialSetName: string
1112
organizationName: string
13+
providerId: string | null
1214
email: string | null
1315
}
1416

17+
type AcceptedState = 'connecting' | 'already-connected'
18+
19+
/**
20+
* Maps credential set provider IDs to OAuth provider IDs
21+
* The credential set stores 'gmail' but the OAuth provider is 'google-email'
22+
*/
23+
function getOAuthProviderId(credentialSetProviderId: string): string {
24+
if (credentialSetProviderId === 'gmail') {
25+
return 'google-email'
26+
}
27+
// outlook is the same in both
28+
return credentialSetProviderId
29+
}
30+
1531
export default function CredentialAccountInvitePage() {
1632
const params = useParams()
1733
const router = useRouter()
@@ -23,7 +39,7 @@ export default function CredentialAccountInvitePage() {
2339
const [loading, setLoading] = useState(true)
2440
const [error, setError] = useState<string | null>(null)
2541
const [accepting, setAccepting] = useState(false)
26-
const [accepted, setAccepted] = useState(false)
42+
const [acceptedState, setAcceptedState] = useState<AcceptedState | null>(null)
2743

2844
useEffect(() => {
2945
async function fetchInvitation() {
@@ -48,7 +64,9 @@ export default function CredentialAccountInvitePage() {
4864

4965
const handleAccept = useCallback(async () => {
5066
if (!session?.user?.id) {
51-
router.push(`/login?callbackUrl=${encodeURIComponent(`/credential-account/${token}`)}`)
67+
// Include invite_flow=true so the login page preserves callbackUrl when linking to signup
68+
const callbackUrl = encodeURIComponent(`/credential-account/${token}`)
69+
router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`)
5270
return
5371
}
5472

@@ -64,13 +82,63 @@ export default function CredentialAccountInvitePage() {
6482
return
6583
}
6684

67-
setAccepted(true)
85+
const data = await res.json()
86+
const credentialSetProviderId = data.providerId || invitation?.providerId
87+
88+
// Check if user already has this provider connected
89+
let isAlreadyConnected = false
90+
if (credentialSetProviderId) {
91+
const oauthProviderId = getOAuthProviderId(credentialSetProviderId)
92+
try {
93+
const connectionsRes = await fetch('/api/auth/oauth/connections')
94+
if (connectionsRes.ok) {
95+
const connectionsData = await connectionsRes.json()
96+
const connections = connectionsData.connections || []
97+
isAlreadyConnected = connections.some(
98+
(conn: { provider: string; accounts?: { id: string }[] }) =>
99+
conn.provider === oauthProviderId && conn.accounts && conn.accounts.length > 0
100+
)
101+
}
102+
} catch {
103+
// If we can't check connections, proceed with OAuth flow
104+
}
105+
}
106+
107+
if (isAlreadyConnected) {
108+
// Already connected - redirect to workspace
109+
setAcceptedState('already-connected')
110+
setTimeout(() => {
111+
router.push('/workspace')
112+
}, 2000)
113+
} else if (credentialSetProviderId === 'gmail' || credentialSetProviderId === 'outlook') {
114+
// Not connected - start OAuth flow
115+
setAcceptedState('connecting')
116+
117+
// Small delay to show success message before redirect
118+
setTimeout(async () => {
119+
try {
120+
const oauthProviderId = getOAuthProviderId(credentialSetProviderId)
121+
await client.oauth2.link({
122+
providerId: oauthProviderId,
123+
callbackURL: `${window.location.origin}/workspace`,
124+
})
125+
} catch (oauthError) {
126+
// OAuth redirect will happen, this catch is for any pre-redirect errors
127+
console.error('OAuth initiation error:', oauthError)
128+
// If OAuth fails, redirect to workspace where they can connect manually
129+
router.push('/workspace')
130+
}
131+
}, 1500)
132+
} else {
133+
// No provider specified - just redirect to workspace
134+
router.push('/workspace')
135+
}
68136
} catch {
69137
setError('Failed to accept invitation')
70138
} finally {
71139
setAccepting(false)
72140
}
73-
}, [session?.user?.id, token, router])
141+
}, [session?.user?.id, token, router, invitation?.providerId])
74142

75143
if (loading || sessionLoading) {
76144
return (
@@ -94,19 +162,49 @@ export default function CredentialAccountInvitePage() {
94162
)
95163
}
96164

97-
if (accepted) {
165+
const ProviderIcon =
166+
invitation?.providerId === 'outlook'
167+
? OutlookIcon
168+
: invitation?.providerId === 'gmail'
169+
? GmailIcon
170+
: Mail
171+
const providerName =
172+
invitation?.providerId === 'outlook'
173+
? 'Outlook'
174+
: invitation?.providerId === 'gmail'
175+
? 'Gmail'
176+
: 'email'
177+
178+
if (acceptedState === 'already-connected') {
98179
return (
99180
<div className='flex min-h-screen items-center justify-center bg-[var(--bg)]'>
100181
<div className='flex max-w-[400px] flex-col items-center gap-[24px] p-[32px]'>
101182
<CheckCircle2 className='h-[48px] w-[48px] text-green-500' />
102-
<p className='font-medium text-[20px] text-[var(--text-primary)]'>Welcome!</p>
183+
<p className='font-medium text-[20px] text-[var(--text-primary)]'>You're all set!</p>
184+
<p className='text-center text-[13px] text-[var(--text-secondary)]'>
185+
You've joined {invitation?.credentialSetName}. Your {providerName} account is already
186+
connected.
187+
</p>
188+
<p className='text-[12px] text-[var(--text-tertiary)]'>Redirecting to workspace...</p>
189+
<Loader2 className='h-[24px] w-[24px] animate-spin text-[var(--text-muted)]' />
190+
</div>
191+
</div>
192+
)
193+
}
194+
195+
if (acceptedState === 'connecting') {
196+
return (
197+
<div className='flex min-h-screen items-center justify-center bg-[var(--bg)]'>
198+
<div className='flex max-w-[400px] flex-col items-center gap-[24px] p-[32px]'>
199+
<ProviderIcon className='h-[48px] w-[48px]' />
200+
<p className='font-medium text-[20px] text-[var(--text-primary)]'>
201+
Connecting to {providerName}...
202+
</p>
103203
<p className='text-center text-[13px] text-[var(--text-secondary)]'>
104-
You've successfully joined {invitation?.credentialSetName}. Connect your OAuth
105-
credentials in Settings → Integrations.
204+
You've joined {invitation?.credentialSetName}. You'll be redirected to connect your{' '}
205+
{providerName} account.
106206
</p>
107-
<Button variant='tertiary' onClick={() => router.push('/workspace')}>
108-
Go to Dashboard
109-
</Button>
207+
<Loader2 className='h-[24px] w-[24px] animate-spin text-[var(--text-muted)]' />
110208
</div>
111209
</div>
112210
)
@@ -116,15 +214,22 @@ export default function CredentialAccountInvitePage() {
116214
<div className='flex min-h-screen items-center justify-center bg-[var(--bg)]'>
117215
<div className='w-full max-w-[400px] p-[32px]'>
118216
<div className='flex flex-col items-center gap-[8px]'>
119-
<Shield className='h-[48px] w-[48px] text-[var(--brand-400)]' />
120-
<p className='font-medium text-[20px] text-[var(--text-primary)]'>Join Credential Set</p>
217+
<ProviderIcon className='h-[48px] w-[48px]' />
218+
<p className='font-medium text-[20px] text-[var(--text-primary)]'>
219+
Join Email Polling Group
220+
</p>
121221
<p className='text-center text-[13px] text-[var(--text-secondary)]'>
122222
You've been invited to join{' '}
123223
<span className='font-medium text-[var(--text-primary)]'>
124224
{invitation?.credentialSetName}
125225
</span>{' '}
126226
by {invitation?.organizationName}
127227
</p>
228+
{invitation?.providerId && (
229+
<p className='mt-[8px] text-center text-[12px] text-[var(--text-tertiary)]'>
230+
You'll be asked to connect your {providerName} account after accepting.
231+
</p>
232+
)}
128233
</div>
129234

130235
<div className='mt-[32px] flex flex-col gap-[16px]'>
@@ -141,7 +246,10 @@ export default function CredentialAccountInvitePage() {
141246
Joining...
142247
</>
143248
) : (
144-
'Accept Invitation'
249+
<>
250+
<ProviderIcon className='mr-[8px] h-[16px] w-[16px]' />
251+
Accept & Connect {providerName}
252+
</>
145253
)}
146254
</Button>
147255
</>
@@ -158,8 +266,8 @@ export default function CredentialAccountInvitePage() {
158266
</div>
159267

160268
<p className='mt-[24px] text-center text-[11px] text-[var(--text-muted)]'>
161-
By joining, you agree to share your OAuth credentials with this credential set for use in
162-
automated workflows.
269+
By joining, you agree to share your {providerName} credentials with this polling group for
270+
use in automated email workflows.
163271
</p>
164272
</div>
165273
</div>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function CredentialSets() {
8080
const [newSetName, setNewSetName] = useState('')
8181
const [newSetDescription, setNewSetDescription] = useState('')
8282
const [newSetProvider, setNewSetProvider] = useState<'gmail' | 'outlook'>('gmail')
83+
const [createError, setCreateError] = useState<string | null>(null)
8384
const [inviteEmails, setInviteEmails] = useState('')
8485
const [isDragging, setIsDragging] = useState(false)
8586
const [leavingMembership, setLeavingMembership] = useState<{
@@ -204,6 +205,7 @@ export function CredentialSets() {
204205

205206
const handleCreateCredentialSet = useCallback(async () => {
206207
if (!newSetName.trim() || !activeOrganization?.id) return
208+
setCreateError(null)
207209
try {
208210
await createCredentialSet.mutateAsync({
209211
organizationId: activeOrganization.id,
@@ -218,6 +220,11 @@ export function CredentialSets() {
218220
setNewSetProvider('gmail')
219221
} catch (error) {
220222
logger.error('Failed to create polling group', error)
223+
if (error instanceof Error) {
224+
setCreateError(error.message)
225+
} else {
226+
setCreateError('Failed to create polling group')
227+
}
221228
}
222229
}, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet])
223230

@@ -247,6 +254,14 @@ export function CredentialSets() {
247254
}
248255
}, [selectedSetId, inviteEmails, createInvitation])
249256

257+
const handleCloseCreateModal = useCallback(() => {
258+
setShowCreateModal(false)
259+
setNewSetName('')
260+
setNewSetDescription('')
261+
setNewSetProvider('gmail')
262+
setCreateError(null)
263+
}, [])
264+
250265
const handleCloseInviteModal = useCallback(() => {
251266
setShowInviteModal(false)
252267
setInviteEmails('')
@@ -573,7 +588,7 @@ export function CredentialSets() {
573588
</div>
574589
)}
575590

576-
<Modal open={showCreateModal} onOpenChange={setShowCreateModal}>
591+
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
577592
<ModalContent size='sm'>
578593
<ModalHeader>Create Polling Group</ModalHeader>
579594
<ModalBody>
@@ -582,7 +597,10 @@ export function CredentialSets() {
582597
<Label>Name</Label>
583598
<Input
584599
value={newSetName}
585-
onChange={(e) => setNewSetName(e.target.value)}
600+
onChange={(e) => {
601+
setNewSetName(e.target.value)
602+
if (createError) setCreateError(null)
603+
}}
586604
placeholder='e.g., Marketing Team'
587605
/>
588606
</div>
@@ -619,10 +637,11 @@ export function CredentialSets() {
619637
account for email polling
620638
</p>
621639
</div>
640+
{createError && <p className='text-[12px] text-[var(--text-error)]'>{createError}</p>}
622641
</div>
623642
</ModalBody>
624643
<ModalFooter>
625-
<Button variant='default' onClick={() => setShowCreateModal(false)}>
644+
<Button variant='default' onClick={handleCloseCreateModal}>
626645
Cancel
627646
</Button>
628647
<Button

0 commit comments

Comments
 (0)