Skip to content

Commit 09bbf1d

Browse files
improvement(stripe) (#308): added orgs, teams
* added organizations, stripe team plan, team management page * added db hook to set active organization * simplified data access patterns, added back environments --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent 41f12d7 commit 09bbf1d

18 files changed

Lines changed: 4408 additions & 218 deletions

File tree

sim/app/api/user/subscription/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server'
2-
import { isProPlan } from '@/lib/subscription'
2+
import { isProPlan, isTeamPlan } from '@/lib/subscription'
33
import { getSession } from '@/lib/auth'
44
import { createLogger } from '@/lib/logs/console-logger'
55

@@ -21,7 +21,10 @@ export async function GET(request: NextRequest) {
2121
// Check if the user is on the Pro plan
2222
const isPro = await isProPlan(session.user.id)
2323

24-
return NextResponse.json({ isPro })
24+
// Check if the user is on the Team plan
25+
const isTeam = await isTeamPlan(session.user.id)
26+
27+
return NextResponse.json({ isPro, isTeam })
2528
} catch (error) {
2629
logger.error('Error checking subscription status:', error)
2730
return NextResponse.json(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getSession } from '@/lib/auth'
3+
import { createLogger } from '@/lib/logs/console-logger'
4+
import { db } from '@/db'
5+
import * as schema from '@/db/schema'
6+
import { eq } from 'drizzle-orm'
7+
8+
const logger = createLogger('TransferSubscriptionAPI')
9+
10+
export async function POST(request: NextRequest) {
11+
try {
12+
// Get the authenticated user
13+
const session = await getSession()
14+
15+
if (!session?.user?.id) {
16+
logger.warn('Unauthorized subscription transfer attempt')
17+
return NextResponse.json(
18+
{ error: 'Unauthorized' },
19+
{ status: 401 }
20+
)
21+
}
22+
23+
// Parse the request body
24+
const body = await request.json()
25+
const { subscriptionId, organizationId } = body
26+
27+
if (!subscriptionId || !organizationId) {
28+
return NextResponse.json(
29+
{ error: 'Missing required fields: subscriptionId and organizationId' },
30+
{ status: 400 }
31+
)
32+
}
33+
34+
logger.info('Transferring subscription to organization', {
35+
userId: session.user.id,
36+
subscriptionId,
37+
organizationId
38+
})
39+
40+
// Verify the user has access to both the subscription and organization
41+
const subscription = await db.select()
42+
.from(schema.subscription)
43+
.where(eq(schema.subscription.id, subscriptionId))
44+
.then(rows => rows[0])
45+
46+
if (!subscription) {
47+
logger.warn('Subscription not found', { subscriptionId })
48+
return NextResponse.json(
49+
{ error: 'Subscription not found' },
50+
{ status: 404 }
51+
)
52+
}
53+
54+
// Verify the subscription belongs to the user
55+
if (subscription.referenceId !== session.user.id) {
56+
logger.warn('Unauthorized subscription transfer - subscription does not belong to user', {
57+
userId: session.user.id,
58+
subscriptionReferenceId: subscription.referenceId
59+
})
60+
return NextResponse.json(
61+
{ error: 'Unauthorized - subscription does not belong to user' },
62+
{ status: 403 }
63+
)
64+
}
65+
66+
// Verify the organization exists
67+
const organization = await db.select()
68+
.from(schema.organization)
69+
.where(eq(schema.organization.id, organizationId))
70+
.then(rows => rows[0])
71+
72+
if (!organization) {
73+
logger.warn('Organization not found', { organizationId })
74+
return NextResponse.json(
75+
{ error: 'Organization not found' },
76+
{ status: 404 }
77+
)
78+
}
79+
80+
// Verify the user has admin access to the organization (is owner or admin)
81+
const member = await db.select()
82+
.from(schema.member)
83+
.where(
84+
eq(schema.member.userId, session.user.id) &&
85+
eq(schema.member.organizationId, organizationId)
86+
)
87+
.then(rows => rows[0])
88+
89+
if (!member || (member.role !== 'owner' && member.role !== 'admin')) {
90+
logger.warn('Unauthorized subscription transfer - user is not admin of organization', {
91+
userId: session.user.id,
92+
organizationId,
93+
memberRole: member?.role
94+
})
95+
return NextResponse.json(
96+
{ error: 'Unauthorized - user is not admin of organization' },
97+
{ status: 403 }
98+
)
99+
}
100+
101+
// Update the subscription to point to the organization instead of the user
102+
await db.update(schema.subscription)
103+
.set({ referenceId: organizationId })
104+
.where(eq(schema.subscription.id, subscriptionId))
105+
106+
logger.info('Successfully transferred subscription to organization', {
107+
subscriptionId,
108+
organizationId,
109+
userId: session.user.id
110+
})
111+
112+
return NextResponse.json({
113+
success: true,
114+
message: 'Subscription transferred successfully'
115+
})
116+
117+
} catch (error) {
118+
logger.error('Error transferring subscription', { error })
119+
return NextResponse.json(
120+
{ error: 'Failed to transfer subscription' },
121+
{ status: 500 }
122+
)
123+
}
124+
}

sim/app/invite/[id]/page.tsx

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

0 commit comments

Comments
 (0)