Skip to content

Commit b979c9e

Browse files
committed
improvement: restructured error and invite pages
1 parent 0a2d289 commit b979c9e

File tree

6 files changed

+337
-331
lines changed

6 files changed

+337
-331
lines changed

apps/sim/app/api/workspaces/invitations/accept/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function GET(req: NextRequest) {
1111

1212
if (!token) {
1313
// Redirect to a page explaining the error
14-
return NextResponse.redirect(new URL('/invitation-error?reason=missing-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
14+
return NextResponse.redirect(new URL('/invite/invite-error?reason=missing-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
1515
}
1616

1717
const session = await getSession()
@@ -30,22 +30,22 @@ export async function GET(req: NextRequest) {
3030
.then(rows => rows[0])
3131

3232
if (!invitation) {
33-
return NextResponse.redirect(new URL('/invitation-error?reason=invalid-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
33+
return NextResponse.redirect(new URL('/invite/invite-error?reason=invalid-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
3434
}
3535

3636
// Check if invitation has expired
3737
if (new Date() > new Date(invitation.expiresAt)) {
38-
return NextResponse.redirect(new URL('/invitation-error?reason=expired', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
38+
return NextResponse.redirect(new URL('/invite/invite-error?reason=expired', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
3939
}
4040

4141
// Check if invitation is already accepted
4242
if (invitation.status !== 'pending') {
43-
return NextResponse.redirect(new URL('/invitation-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
43+
return NextResponse.redirect(new URL('/invite/invite-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
4444
}
4545

4646
// Check if invitation email matches the logged-in user
4747
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) {
48-
return NextResponse.redirect(new URL('/invitation-error?reason=email-mismatch', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
48+
return NextResponse.redirect(new URL('/invite/invite-error?reason=email-mismatch', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
4949
}
5050

5151
// Get the workspace details
@@ -56,7 +56,7 @@ export async function GET(req: NextRequest) {
5656
.then(rows => rows[0])
5757

5858
if (!workspaceDetails) {
59-
return NextResponse.redirect(new URL('/invitation-error?reason=workspace-not-found', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
59+
return NextResponse.redirect(new URL('/invite/invite-error?reason=workspace-not-found', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
6060
}
6161

6262
// Check if user is already a member
@@ -109,6 +109,6 @@ export async function GET(req: NextRequest) {
109109
return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
110110
} catch (error) {
111111
console.error('Error accepting invitation:', error)
112-
return NextResponse.redirect(new URL('/invitation-error?reason=server-error', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
112+
return NextResponse.redirect(new URL('/invite/invite-error?reason=server-error', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
113113
}
114114
}

apps/sim/app/invitation-error/page.tsx

Lines changed: 0 additions & 75 deletions
This file was deleted.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { useParams, useRouter, useSearchParams } from 'next/navigation'
5+
import { CheckCircle, XCircle } from 'lucide-react'
6+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
7+
import { Button } from '@/components/ui/button'
8+
import {
9+
Card,
10+
CardContent,
11+
CardDescription,
12+
CardFooter,
13+
CardHeader,
14+
CardTitle,
15+
} from '@/components/ui/card'
16+
import { LoadingAgent } from '@/components/ui/loading-agent'
17+
import { client, useSession } from '@/lib/auth-client'
18+
19+
export default function Invite() {
20+
const router = useRouter()
21+
const params = useParams()
22+
const invitationId = params.id as string
23+
const searchParams = useSearchParams()
24+
const { data: session, isPending, error: sessionError } = useSession()
25+
const [invitation, setInvitation] = useState<any>(null)
26+
const [organization, setOrganization] = useState<any>(null)
27+
const [isLoading, setIsLoading] = useState(true)
28+
const [error, setError] = useState<string | null>(null)
29+
const [isAccepting, setIsAccepting] = useState(false)
30+
const [accepted, setAccepted] = useState(false)
31+
const [isNewUser, setIsNewUser] = useState(false)
32+
33+
// Check if this is a new user vs. existing user
34+
useEffect(() => {
35+
const isNew = searchParams.get('new') === 'true'
36+
setIsNewUser(isNew)
37+
}, [searchParams])
38+
39+
// Fetch invitation details
40+
useEffect(() => {
41+
async function fetchInvitation() {
42+
try {
43+
setIsLoading(true)
44+
const { data } = await client.organization.getInvitation({
45+
query: { id: invitationId },
46+
})
47+
48+
if (data) {
49+
setInvitation(data)
50+
51+
// Get organization details if we have the invitation
52+
if (data.organizationId) {
53+
const orgResponse = await client.organization.getFullOrganization({
54+
query: { organizationId: data.organizationId },
55+
})
56+
setOrganization(orgResponse.data)
57+
}
58+
} else {
59+
setError('Invitation not found or has expired')
60+
}
61+
} catch (err: any) {
62+
setError(err.message || 'Failed to load invitation')
63+
} finally {
64+
setIsLoading(false)
65+
}
66+
}
67+
68+
// Only fetch if the user is logged in
69+
if (session?.user && invitationId) {
70+
fetchInvitation()
71+
}
72+
}, [invitationId, session?.user])
73+
74+
// Handle invitation acceptance
75+
const handleAcceptInvitation = async () => {
76+
if (!session?.user) return
77+
78+
try {
79+
setIsAccepting(true)
80+
console.log('Accepting invitation:', invitationId, 'for user:', session.user.id)
81+
82+
const response = await client.organization.acceptInvitation({
83+
invitationId,
84+
})
85+
86+
console.log('Invitation acceptance response:', response)
87+
88+
// Explicitly verify membership was created
89+
try {
90+
const orgResponse = await client.organization.getFullOrganization({
91+
query: { organizationId: invitation.organizationId },
92+
})
93+
94+
console.log('Organization members after acceptance:', orgResponse.data?.members)
95+
96+
const isMember = orgResponse.data?.members?.some(
97+
(member: any) => member.userId === session.user.id
98+
)
99+
100+
if (!isMember) {
101+
console.error('User was not added as a member after invitation acceptance')
102+
throw new Error('Failed to add you as a member. Please contact support.')
103+
}
104+
105+
// Set the active organization to the one the user just joined
106+
await client.organization.setActive({
107+
organizationId: invitation.organizationId,
108+
})
109+
110+
console.log('Successfully set active organization:', invitation.organizationId)
111+
} catch (memberCheckErr: any) {
112+
console.error('Error verifying membership:', memberCheckErr)
113+
throw memberCheckErr
114+
}
115+
116+
setAccepted(true)
117+
118+
// Redirect to the workspace after a short delay
119+
setTimeout(() => {
120+
router.push('/w')
121+
}, 2000)
122+
} catch (err: any) {
123+
console.error('Error accepting invitation:', err)
124+
setError(err.message || 'Failed to accept invitation')
125+
} finally {
126+
setIsAccepting(false)
127+
}
128+
}
129+
130+
// Show login/signup prompt if not logged in
131+
if (!session?.user && !isPending) {
132+
return (
133+
<div className="flex min-h-screen flex-col items-center justify-center p-4">
134+
<Card className="w-full max-w-md">
135+
<CardHeader>
136+
<CardTitle>You've been invited to join a team</CardTitle>
137+
<CardDescription>
138+
{isNewUser
139+
? 'Create an account to join this team on Sim Studio'
140+
: 'Sign in to your account to accept this invitation'}
141+
</CardDescription>
142+
</CardHeader>
143+
<CardFooter className="flex flex-col space-y-2">
144+
{isNewUser ? (
145+
<>
146+
<Button
147+
className="w-full"
148+
onClick={() => router.push(`/signup?redirect=/invite/${invitationId}`)}
149+
>
150+
Create an account
151+
</Button>
152+
<Button
153+
variant="outline"
154+
className="w-full"
155+
onClick={() => router.push(`/login?redirect=/invite/${invitationId}`)}
156+
>
157+
I already have an account
158+
</Button>
159+
</>
160+
) : (
161+
<>
162+
<Button
163+
className="w-full"
164+
onClick={() => router.push(`/login?redirect=/invite/${invitationId}`)}
165+
>
166+
Sign in
167+
</Button>
168+
<Button
169+
variant="outline"
170+
className="w-full"
171+
onClick={() => router.push(`/signup?redirect=/invite/${invitationId}&new=true`)}
172+
>
173+
Create an account
174+
</Button>
175+
</>
176+
)}
177+
</CardFooter>
178+
</Card>
179+
</div>
180+
)
181+
}
182+
183+
// Show loading state
184+
if (isLoading || isPending) {
185+
return (
186+
<div className="flex min-h-screen flex-col items-center justify-center p-4">
187+
<LoadingAgent size="lg" />
188+
<p className="mt-4 text-sm text-muted-foreground">Loading invitation...</p>
189+
</div>
190+
)
191+
}
192+
193+
// Show error state
194+
if (error) {
195+
return (
196+
<div className="flex min-h-screen flex-col items-center justify-center p-4">
197+
<Alert variant="destructive" className="max-w-md">
198+
<XCircle className="h-4 w-4" />
199+
<AlertTitle>Error</AlertTitle>
200+
<AlertDescription>{error}</AlertDescription>
201+
</Alert>
202+
</div>
203+
)
204+
}
205+
206+
// Show success state
207+
if (accepted) {
208+
return (
209+
<div className="flex min-h-screen flex-col items-center justify-center p-4">
210+
<Alert className="max-w-md bg-green-50">
211+
<CheckCircle className="h-4 w-4 text-green-500" />
212+
<AlertTitle>Invitation Accepted</AlertTitle>
213+
<AlertDescription>
214+
You have successfully joined {organization?.name}. Redirecting to your workspace...
215+
</AlertDescription>
216+
</Alert>
217+
</div>
218+
)
219+
}
220+
221+
// Show invitation details
222+
return (
223+
<div className="flex min-h-screen flex-col items-center justify-center p-4">
224+
<Card className="w-full max-w-md">
225+
<CardHeader>
226+
<CardTitle>Team Invitation</CardTitle>
227+
<CardDescription>
228+
You've been invited to join{' '}
229+
<span className="font-medium">{organization?.name || 'a team'}</span>
230+
</CardDescription>
231+
</CardHeader>
232+
<CardContent>
233+
<p className="text-sm text-muted-foreground">
234+
{invitation?.inviterId ? 'A team member has' : 'You have'} invited you to collaborate in{' '}
235+
{organization?.name || 'their workspace'}.
236+
</p>
237+
</CardContent>
238+
<CardFooter className="flex justify-between">
239+
<Button variant="outline" onClick={() => router.push('/')}>
240+
Decline
241+
</Button>
242+
<Button onClick={handleAcceptInvitation} disabled={isAccepting}>
243+
{isAccepting ? <LoadingAgent size="sm" /> : null}
244+
<span className={isAccepting ? 'ml-2' : ''}>Accept Invitation</span>
245+
</Button>
246+
</CardFooter>
247+
</Card>
248+
</div>
249+
)
250+
}

0 commit comments

Comments
 (0)