Skip to content

Commit d6e4c91

Browse files
authored
fix(invitations): preserve tokens after error (#2806)
1 parent e3fa40a commit d6e4c91

File tree

3 files changed

+219
-48
lines changed

3 files changed

+219
-48
lines changed

apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts

Lines changed: 192 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@ import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
22
import { NextRequest } from 'next/server'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
/**
6-
* Tests for workspace invitation by ID API route
7-
* Tests GET (details + token acceptance), DELETE (cancellation)
8-
*
9-
* @vitest-environment node
10-
*/
11-
125
const mockGetSession = vi.fn()
136
const mockHasWorkspaceAdminAccess = vi.fn()
147

@@ -227,7 +220,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
227220
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
228221
})
229222

230-
it('should redirect to error page when invitation expired', async () => {
223+
it('should redirect to error page with token preserved when invitation expired', async () => {
231224
const session = createSession({
232225
userId: mockUser.id,
233226
@@ -250,12 +243,13 @@ describe('Workspace Invitation [invitationId] API Route', () => {
250243
const response = await GET(request, { params })
251244

252245
expect(response.status).toBe(307)
253-
expect(response.headers.get('location')).toBe(
254-
'https://test.sim.ai/invite/invitation-789?error=expired'
246+
const location = response.headers.get('location')
247+
expect(location).toBe(
248+
'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123'
255249
)
256250
})
257251

258-
it('should redirect to error page when email mismatch', async () => {
252+
it('should redirect to error page with token preserved when email mismatch', async () => {
259253
const session = createSession({
260254
userId: mockUser.id,
261255
@@ -277,12 +271,13 @@ describe('Workspace Invitation [invitationId] API Route', () => {
277271
const response = await GET(request, { params })
278272

279273
expect(response.status).toBe(307)
280-
expect(response.headers.get('location')).toBe(
281-
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
274+
const location = response.headers.get('location')
275+
expect(location).toBe(
276+
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
282277
)
283278
})
284279

285-
it('should return 404 when invitation not found', async () => {
280+
it('should return 404 when invitation not found (without token)', async () => {
286281
const session = createSession({ userId: mockUser.id, email: mockUser.email })
287282
mockGetSession.mockResolvedValue(session)
288283
dbSelectResults = [[]]
@@ -296,6 +291,189 @@ describe('Workspace Invitation [invitationId] API Route', () => {
296291
expect(response.status).toBe(404)
297292
expect(data).toEqual({ error: 'Invitation not found or has expired' })
298293
})
294+
295+
it('should redirect to error page with token preserved when invitation not found (with token)', async () => {
296+
const session = createSession({ userId: mockUser.id, email: mockUser.email })
297+
mockGetSession.mockResolvedValue(session)
298+
dbSelectResults = [[]]
299+
300+
const request = new NextRequest(
301+
'http://localhost/api/workspaces/invitations/non-existent?token=some-invalid-token'
302+
)
303+
const params = Promise.resolve({ invitationId: 'non-existent' })
304+
305+
const response = await GET(request, { params })
306+
307+
expect(response.status).toBe(307)
308+
const location = response.headers.get('location')
309+
expect(location).toBe(
310+
'https://test.sim.ai/invite/non-existent?error=invalid-token&token=some-invalid-token'
311+
)
312+
})
313+
314+
it('should redirect to error page with token preserved when invitation already processed', async () => {
315+
const session = createSession({
316+
userId: mockUser.id,
317+
318+
name: mockUser.name,
319+
})
320+
mockGetSession.mockResolvedValue(session)
321+
322+
const acceptedInvitation = {
323+
...mockInvitation,
324+
status: 'accepted',
325+
}
326+
327+
dbSelectResults = [[acceptedInvitation], [mockWorkspace]]
328+
329+
const request = new NextRequest(
330+
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
331+
)
332+
const params = Promise.resolve({ invitationId: 'token-abc123' })
333+
334+
const response = await GET(request, { params })
335+
336+
expect(response.status).toBe(307)
337+
const location = response.headers.get('location')
338+
expect(location).toBe(
339+
'https://test.sim.ai/invite/invitation-789?error=already-processed&token=token-abc123'
340+
)
341+
})
342+
343+
it('should redirect to error page with token preserved when workspace not found', async () => {
344+
const session = createSession({
345+
userId: mockUser.id,
346+
347+
name: mockUser.name,
348+
})
349+
mockGetSession.mockResolvedValue(session)
350+
351+
dbSelectResults = [[mockInvitation], []]
352+
353+
const request = new NextRequest(
354+
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
355+
)
356+
const params = Promise.resolve({ invitationId: 'token-abc123' })
357+
358+
const response = await GET(request, { params })
359+
360+
expect(response.status).toBe(307)
361+
const location = response.headers.get('location')
362+
expect(location).toBe(
363+
'https://test.sim.ai/invite/invitation-789?error=workspace-not-found&token=token-abc123'
364+
)
365+
})
366+
367+
it('should redirect to error page with token preserved when user not found', async () => {
368+
const session = createSession({
369+
userId: mockUser.id,
370+
371+
name: mockUser.name,
372+
})
373+
mockGetSession.mockResolvedValue(session)
374+
375+
dbSelectResults = [[mockInvitation], [mockWorkspace], []]
376+
377+
const request = new NextRequest(
378+
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
379+
)
380+
const params = Promise.resolve({ invitationId: 'token-abc123' })
381+
382+
const response = await GET(request, { params })
383+
384+
expect(response.status).toBe(307)
385+
const location = response.headers.get('location')
386+
expect(location).toBe(
387+
'https://test.sim.ai/invite/invitation-789?error=user-not-found&token=token-abc123'
388+
)
389+
})
390+
391+
it('should URL encode special characters in token when preserving in error redirects', async () => {
392+
const session = createSession({
393+
userId: mockUser.id,
394+
395+
name: mockUser.name,
396+
})
397+
mockGetSession.mockResolvedValue(session)
398+
399+
dbSelectResults = [
400+
[mockInvitation],
401+
[mockWorkspace],
402+
[{ ...mockUser, email: '[email protected]' }],
403+
]
404+
405+
const specialToken = 'token+with/special=chars&more'
406+
const request = new NextRequest(
407+
`http://localhost/api/workspaces/invitations/token-abc123?token=${encodeURIComponent(specialToken)}`
408+
)
409+
const params = Promise.resolve({ invitationId: 'token-abc123' })
410+
411+
const response = await GET(request, { params })
412+
413+
expect(response.status).toBe(307)
414+
const location = response.headers.get('location')
415+
expect(location).toContain('error=email-mismatch')
416+
expect(location).toContain(`token=${encodeURIComponent(specialToken)}`)
417+
})
418+
})
419+
420+
describe('Token Preservation - Full Flow Scenario', () => {
421+
it('should preserve token through email mismatch so user can retry with correct account', async () => {
422+
const wrongSession = createSession({
423+
userId: 'wrong-user',
424+
425+
name: 'Wrong User',
426+
})
427+
mockGetSession.mockResolvedValue(wrongSession)
428+
429+
dbSelectResults = [
430+
[mockInvitation],
431+
[mockWorkspace],
432+
[{ id: 'wrong-user', email: '[email protected]' }],
433+
]
434+
435+
const request1 = new NextRequest(
436+
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
437+
)
438+
const params1 = Promise.resolve({ invitationId: 'token-abc123' })
439+
440+
const response1 = await GET(request1, { params: params1 })
441+
442+
expect(response1.status).toBe(307)
443+
const location1 = response1.headers.get('location')
444+
expect(location1).toBe(
445+
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
446+
)
447+
448+
vi.clearAllMocks()
449+
dbSelectCallIndex = 0
450+
451+
const correctSession = createSession({
452+
userId: mockUser.id,
453+
454+
name: mockUser.name,
455+
})
456+
mockGetSession.mockResolvedValue(correctSession)
457+
458+
dbSelectResults = [
459+
[mockInvitation],
460+
[mockWorkspace],
461+
[{ ...mockUser, email: '[email protected]' }],
462+
[],
463+
]
464+
465+
const request2 = new NextRequest(
466+
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
467+
)
468+
const params2 = Promise.resolve({ invitationId: 'token-abc123' })
469+
470+
const response2 = await GET(request2, { params: params2 })
471+
472+
expect(response2.status).toBe(307)
473+
expect(response2.headers.get('location')).toBe(
474+
'https://test.sim.ai/workspace/workspace-456/w'
475+
)
476+
})
299477
})
300478

301479
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {

apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export async function GET(
3131
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
3232

3333
if (!session?.user?.id) {
34-
// For token-based acceptance flows, redirect to login
3534
if (isAcceptFlow) {
3635
return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl()))
3736
}
@@ -51,17 +50,19 @@ export async function GET(
5150

5251
if (!invitation) {
5352
if (isAcceptFlow) {
53+
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
5454
return NextResponse.redirect(
55-
new URL(`/invite/${invitationId}?error=invalid-token`, getBaseUrl())
55+
new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl())
5656
)
5757
}
5858
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
5959
}
6060

6161
if (new Date() > new Date(invitation.expiresAt)) {
6262
if (isAcceptFlow) {
63+
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
6364
return NextResponse.redirect(
64-
new URL(`/invite/${invitation.id}?error=expired`, getBaseUrl())
65+
new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl())
6566
)
6667
}
6768
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
@@ -75,17 +76,20 @@ export async function GET(
7576

7677
if (!workspaceDetails) {
7778
if (isAcceptFlow) {
79+
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
7880
return NextResponse.redirect(
79-
new URL(`/invite/${invitation.id}?error=workspace-not-found`, getBaseUrl())
81+
new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl())
8082
)
8183
}
8284
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
8385
}
8486

8587
if (isAcceptFlow) {
88+
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
89+
8690
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
8791
return NextResponse.redirect(
88-
new URL(`/invite/${invitation.id}?error=already-processed`, getBaseUrl())
92+
new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl())
8993
)
9094
}
9195

@@ -100,15 +104,15 @@ export async function GET(
100104

101105
if (!userData) {
102106
return NextResponse.redirect(
103-
new URL(`/invite/${invitation.id}?error=user-not-found`, getBaseUrl())
107+
new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl())
104108
)
105109
}
106110

107111
const isValidMatch = userEmail === invitationEmail
108112

109113
if (!isValidMatch) {
110114
return NextResponse.redirect(
111-
new URL(`/invite/${invitation.id}?error=email-mismatch`, getBaseUrl())
115+
new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl())
112116
)
113117
}
114118

0 commit comments

Comments
 (0)