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>
This commit is contained in:
Emir Karabeg
2025-04-28 10:23:01 -07:00
committed by GitHub
co-authored by Waleed Latif
parent 41f12d7b04
commit 09bbf1d6ff
18 changed files with 4410 additions and 220 deletions
+5 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { isProPlan } from '@/lib/subscription'
import { isProPlan, isTeamPlan } from '@/lib/subscription'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
@@ -21,7 +21,10 @@ export async function GET(request: NextRequest) {
// Check if the user is on the Pro plan
const isPro = await isProPlan(session.user.id)
return NextResponse.json({ isPro })
// Check if the user is on the Team plan
const isTeam = await isTeamPlan(session.user.id)
return NextResponse.json({ isPro, isTeam })
} catch (error) {
logger.error('Error checking subscription status:', error)
return NextResponse.json(
@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { eq } from 'drizzle-orm'
const logger = createLogger('TransferSubscriptionAPI')
export async function POST(request: NextRequest) {
try {
// Get the authenticated user
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized subscription transfer attempt')
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Parse the request body
const body = await request.json()
const { subscriptionId, organizationId } = body
if (!subscriptionId || !organizationId) {
return NextResponse.json(
{ error: 'Missing required fields: subscriptionId and organizationId' },
{ status: 400 }
)
}
logger.info('Transferring subscription to organization', {
userId: session.user.id,
subscriptionId,
organizationId
})
// Verify the user has access to both the subscription and organization
const subscription = await db.select()
.from(schema.subscription)
.where(eq(schema.subscription.id, subscriptionId))
.then(rows => rows[0])
if (!subscription) {
logger.warn('Subscription not found', { subscriptionId })
return NextResponse.json(
{ error: 'Subscription not found' },
{ status: 404 }
)
}
// Verify the subscription belongs to the user
if (subscription.referenceId !== session.user.id) {
logger.warn('Unauthorized subscription transfer - subscription does not belong to user', {
userId: session.user.id,
subscriptionReferenceId: subscription.referenceId
})
return NextResponse.json(
{ error: 'Unauthorized - subscription does not belong to user' },
{ status: 403 }
)
}
// Verify the organization exists
const organization = await db.select()
.from(schema.organization)
.where(eq(schema.organization.id, organizationId))
.then(rows => rows[0])
if (!organization) {
logger.warn('Organization not found', { organizationId })
return NextResponse.json(
{ error: 'Organization not found' },
{ status: 404 }
)
}
// Verify the user has admin access to the organization (is owner or admin)
const member = await db.select()
.from(schema.member)
.where(
eq(schema.member.userId, session.user.id) &&
eq(schema.member.organizationId, organizationId)
)
.then(rows => rows[0])
if (!member || (member.role !== 'owner' && member.role !== 'admin')) {
logger.warn('Unauthorized subscription transfer - user is not admin of organization', {
userId: session.user.id,
organizationId,
memberRole: member?.role
})
return NextResponse.json(
{ error: 'Unauthorized - user is not admin of organization' },
{ status: 403 }
)
}
// Update the subscription to point to the organization instead of the user
await db.update(schema.subscription)
.set({ referenceId: organizationId })
.where(eq(schema.subscription.id, subscriptionId))
logger.info('Successfully transferred subscription to organization', {
subscriptionId,
organizationId,
userId: session.user.id
})
return NextResponse.json({
success: true,
message: 'Subscription transferred successfully'
})
} catch (error) {
logger.error('Error transferring subscription', { error })
return NextResponse.json(
{ error: 'Failed to transfer subscription' },
{ status: 500 }
)
}
}
+248
View File
@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams, useParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth-client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { XCircle, CheckCircle } from 'lucide-react'
export default function InvitePage() {
const router = useRouter()
const params = useParams()
const invitationId = params.id as string
const searchParams = useSearchParams()
const { data: session, isPending, error: sessionError } = useSession()
const [invitation, setInvitation] = useState<any>(null)
const [organization, setOrganization] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isAccepting, setIsAccepting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [isNewUser, setIsNewUser] = useState(false)
// Check if this is a new user vs. existing user
useEffect(() => {
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
}, [searchParams])
// Fetch invitation details
useEffect(() => {
async function fetchInvitation() {
try {
setIsLoading(true)
const { data } = await client.organization.getInvitation({
query: { id: invitationId }
})
if (data) {
setInvitation(data)
// Get organization details if we have the invitation
if (data.organizationId) {
const orgResponse = await client.organization.getFullOrganization({
query: { organizationId: data.organizationId }
})
setOrganization(orgResponse.data)
}
} else {
setError('Invitation not found or has expired')
}
} catch (err: any) {
setError(err.message || 'Failed to load invitation')
} finally {
setIsLoading(false)
}
}
// Only fetch if the user is logged in
if (session?.user && invitationId) {
fetchInvitation()
}
}, [invitationId, session?.user])
// Handle invitation acceptance
const handleAcceptInvitation = async () => {
if (!session?.user) return
try {
setIsAccepting(true)
console.log("Accepting invitation:", invitationId, "for user:", session.user.id);
const response = await client.organization.acceptInvitation({
invitationId
})
console.log("Invitation acceptance response:", response);
// Explicitly verify membership was created
try {
const orgResponse = await client.organization.getFullOrganization({
query: { organizationId: invitation.organizationId }
});
console.log("Organization members after acceptance:", orgResponse.data?.members);
const isMember = orgResponse.data?.members?.some(
(member: any) => member.userId === session.user.id
);
if (!isMember) {
console.error("User was not added as a member after invitation acceptance");
throw new Error("Failed to add you as a member. Please contact support.");
}
// Set the active organization to the one the user just joined
await client.organization.setActive({
organizationId: invitation.organizationId
});
console.log("Successfully set active organization:", invitation.organizationId);
} catch (memberCheckErr: any) {
console.error("Error verifying membership:", memberCheckErr);
throw memberCheckErr;
}
setAccepted(true)
// Redirect to the workspace after a short delay
setTimeout(() => {
router.push('/w')
}, 2000)
} catch (err: any) {
console.error("Error accepting invitation:", err);
setError(err.message || 'Failed to accept invitation')
} finally {
setIsAccepting(false)
}
}
// Show login/signup prompt if not logged in
if (!session?.user && !isPending) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>You've been invited to join a team</CardTitle>
<CardDescription>
{isNewUser ?
"Create an account to join this team on Sim Studio" :
"Sign in to your account to accept this invitation"}
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col space-y-2">
{isNewUser ? (
<>
<Button
className="w-full"
onClick={() => router.push(`/signup?redirect=/invite/${invitationId}`)}
>
Create an account
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => router.push(`/login?redirect=/invite/${invitationId}`)}
>
I already have an account
</Button>
</>
) : (
<>
<Button
className="w-full"
onClick={() => router.push(`/login?redirect=/invite/${invitationId}`)}
>
Sign in
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => router.push(`/signup?redirect=/invite/${invitationId}&new=true`)}
>
Create an account
</Button>
</>
)}
</CardFooter>
</Card>
</div>
)
}
// Show loading state
if (isLoading || isPending) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<LoadingAgent size="lg" />
<p className="mt-4 text-sm text-muted-foreground">Loading invitation...</p>
</div>
)
}
// Show error state
if (error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Alert variant="destructive" className="max-w-md">
<XCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)
}
// Show success state
if (accepted) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Alert className="max-w-md bg-green-50">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertTitle>Invitation Accepted</AlertTitle>
<AlertDescription>
You have successfully joined {organization?.name}. Redirecting to your workspace...
</AlertDescription>
</Alert>
</div>
)
}
// Show invitation details
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Team Invitation</CardTitle>
<CardDescription>
You've been invited to join{' '}
<span className="font-medium">{organization?.name || 'a team'}</span>
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{invitation?.inviterId ? 'A team member has' : 'You have'} invited you to collaborate in {organization?.name || 'their workspace'}.
</p>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => router.push('/')}>
Decline
</Button>
<Button
onClick={handleAcceptInvitation}
disabled={isAccepting}
>
{isAccepting ? <LoadingAgent size="sm" /> : null}
<span className={isAccepting ? "ml-2" : ""}>
Accept Invitation
</span>
</Button>
</CardFooter>
</Card>
</div>
)
}
@@ -1,16 +1,16 @@
import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard } from 'lucide-react'
import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard, Users } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isDev } from '@/lib/environment'
interface SettingsNavigationProps {
activeSection: string
onSectionChange: (
section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription'
section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
) => void
}
type NavigationItem = {
id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription'
id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
label: string
icon: React.ComponentType<{ className?: string }>
hideInDev?: boolean
@@ -48,6 +48,12 @@ const allNavigationItems: NavigationItem[] = [
icon: CreditCard,
hideInDev: true,
},
{
id: 'team',
label: 'Team',
icon: Users,
hideInDev: true,
},
]
export function SettingsNavigation({ activeSection, onSectionChange }: SettingsNavigationProps) {
@@ -1,18 +1,37 @@
import { useState, useEffect } from 'react'
import { client, useSession } from '@/lib/auth-client'
import { client, useSession, useActiveOrganization } from '@/lib/auth-client'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('Subscription')
interface SubscriptionProps {
onOpenChange: (open: boolean) => void
}
export function Subscription({ onOpenChange }: SubscriptionProps) {
const { data: session } = useSession()
const useSubscriptionData = (userId: string | null | undefined, activeOrgId: string | null | undefined) => {
const [isPro, setIsPro] = useState<boolean>(false)
const [isTeam, setIsTeam] = useState<boolean>(false)
const [usageData, setUsageData] = useState<{
percentUsed: number;
isWarning: boolean;
@@ -26,76 +45,166 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
currentUsage: 0,
limit: 0
})
const [loading, setLoading] = useState<boolean>(true)
const [subscriptionData, setSubscriptionData] = useState<any>(null)
const [isCanceling, setIsCanceling] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function checkSubscriptionStatus() {
if (session?.user?.id) {
try {
setLoading(true)
setError(null)
async function loadSubscriptionData() {
if (!userId) return
try {
setLoading(true)
setError(null)
// Fetch subscription status and usage data in parallel
const [proStatusResponse, usageResponse] = await Promise.all([
fetch('/api/user/subscription'),
fetch('/api/user/usage')
])
if (!proStatusResponse.ok) {
throw new Error('Failed to fetch subscription status')
}
if (!usageResponse.ok) {
throw new Error('Failed to fetch usage data')
}
// Process the responses
const proStatusData = await proStatusResponse.json()
setIsPro(proStatusData.isPro)
setIsTeam(proStatusData.isTeam)
const usageDataResponse = await usageResponse.json()
setUsageData(usageDataResponse)
logger.info('Subscription status and usage data retrieved', {
isPro: proStatusData.isPro,
isTeam: proStatusData.isTeam,
usage: usageDataResponse
})
// Main subscription logic - prioritize organization team subscription
let activeSubscription = null
// First check if user has an active organization with a team subscription
if (activeOrgId) {
logger.info('Checking organization subscription first', { orgId: activeOrgId })
// Fetch subscription status from API
const proStatusResponse = await fetch('/api/user/subscription')
if (!proStatusResponse.ok) {
throw new Error('Failed to fetch subscription status')
}
const proStatusData = await proStatusResponse.json()
setIsPro(proStatusData.isPro)
// Get the organization's subscription
const { data: orgSubscriptions, error: orgSubError } = await client.subscription.list({
query: { referenceId: activeOrgId }
})
// Fetch usage data from API
const usageResponse = await fetch('/api/user/usage')
if (!usageResponse.ok) {
throw new Error('Failed to fetch usage data')
}
const usageData = await usageResponse.json()
setUsageData(usageData)
// Fetch detailed subscription data
const { data, error: subError } = await client.subscription.list()
if (subError) {
console.error('Error fetching subscription details', subError)
// Continue with basic subscription info we already have
if (orgSubError) {
logger.error('Error fetching organization subscription details', orgSubError)
} else {
// Find active subscription
const activeSubscription = data?.find(
// Find active team subscription for the organization
activeSubscription = orgSubscriptions?.find(
sub => sub.status === 'active' && sub.plan === 'team'
)
if (activeSubscription) {
logger.info('Using organization team subscription as primary', {
id: activeSubscription.id,
seats: activeSubscription.seats
})
}
}
}
// If no org team subscription was found, check for personal subscription
if (!activeSubscription) {
// Fetch detailed subscription data for the user
const { data: userSubscriptions, error: userSubError } = await client.subscription.list()
if (userSubError) {
logger.error('Error fetching user subscription details', userSubError)
} else {
// Find active subscription for the user
activeSubscription = userSubscriptions?.find(
sub => sub.status === 'active'
)
setSubscriptionData(activeSubscription)
}
} catch (error) {
console.error('Error checking subscription status:', error)
} finally {
setLoading(false)
}
if (activeSubscription) {
logger.info('Using active subscription', {
id: activeSubscription.id,
plan: activeSubscription.plan,
status: activeSubscription.status
})
setSubscriptionData(activeSubscription)
} else {
logger.warn('No active subscription found')
}
} catch (error) {
logger.error('Error checking subscription status:', error)
setError('Failed to load subscription data')
} finally {
setLoading(false)
}
}
checkSubscriptionStatus()
}, [session?.user?.id])
loadSubscriptionData()
}, [userId, activeOrgId])
const handleUpgrade = async () => {
return { isPro, isTeam, usageData, subscriptionData, loading, error }
}
export function Subscription({ onOpenChange }: SubscriptionProps) {
const { data: session } = useSession()
const { data: activeOrg } = useActiveOrganization()
const {
isPro,
isTeam,
usageData,
subscriptionData,
loading,
error: subscriptionError
} = useSubscriptionData(session?.user?.id, activeOrg?.id)
const [isCanceling, setIsCanceling] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [isTeamDialogOpen, setIsTeamDialogOpen] = useState<boolean>(false)
const [seats, setSeats] = useState<number>(1)
const [isUpgradingTeam, setIsUpgradingTeam] = useState<boolean>(false)
const [isUpgrading, setIsUpgrading] = useState<boolean>(false)
// Set error from subscription hook if there is one
useEffect(() => {
if (subscriptionError) {
setError(subscriptionError)
}
}, [subscriptionError])
const handleUpgrade = async (plan: string) => {
if (!session?.user) {
setError('You need to be logged in to upgrade your subscription')
return
}
setIsUpgrading(true)
setError(null)
try {
const { error } = await client.subscription.upgrade({
plan: 'pro',
plan: plan,
successUrl: window.location.href,
cancelUrl: window.location.href,
})
if (error) {
setError(error.message || 'There was an error upgrading your subscription')
setError(error.message || `There was an error upgrading to the ${plan} plan`)
logger.error('Subscription upgrade error:', error)
}
} catch (error: any) {
setError(error.message || 'There was an error upgrading your subscription')
logger.error('Subscription upgrade exception:', error)
setError(error.message || `There was an unexpected error upgrading to the ${plan} plan`)
} finally {
setIsUpgrading(false)
}
}
@@ -106,6 +215,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
setIsCanceling(true)
setError(null)
try {
const { error } = await client.subscription.cancel({
@@ -114,14 +224,51 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
if (error) {
setError(error.message || 'There was an error canceling your subscription')
logger.error('Subscription cancellation error:', error)
}
} catch (error: any) {
setError(error.message || 'There was an error canceling your subscription')
logger.error('Subscription cancellation exception:', error)
setError(error.message || 'There was an unexpected error canceling your subscription')
} finally {
setIsCanceling(false)
}
}
const handleTeamUpgrade = () => {
setIsTeamDialogOpen(true)
}
const confirmTeamUpgrade = async () => {
if (!session?.user) {
setError('You need to be logged in to upgrade your team subscription')
return
}
setIsUpgradingTeam(true)
setError(null)
try {
const { error } = await client.subscription.upgrade({
plan: 'team',
successUrl: window.location.href,
cancelUrl: window.location.href,
seats: seats
})
if (error) {
setError(error.message || 'There was an error upgrading to the team plan')
logger.error('Team subscription upgrade error:', error)
} else {
setIsTeamDialogOpen(false)
}
} catch (error: any) {
logger.error('Team subscription upgrade exception:', error)
setError(error.message || 'There was an unexpected error upgrading to the team plan')
} finally {
setIsUpgradingTeam(false)
}
}
return (
<div className="p-6 space-y-6">
<h3 className="text-lg font-medium">Subscription Plans</h3>
@@ -148,10 +295,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
)}
{loading ? (
<div className="flex items-center justify-center py-8">
<LoadingAgent size="sm" />
<span className="ml-2">Loading subscription details...</span>
</div>
<SubscriptionSkeleton />
) : (
<>
<div className="grid gap-6 md:grid-cols-2">
@@ -199,25 +343,26 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
onClick={handleCancel}
disabled={isCanceling}
>
{isCanceling && <LoadingAgent size="sm" />}
<span className={isCanceling ? "ml-2" : ""}>Downgrade</span>
{isCanceling ? <ButtonSkeleton /> : (
<span>Downgrade</span>
)}
</Button>
)}
</div>
</div>
{/* Pro Tier */}
<div className={`border rounded-lg p-4 ${isPro ? 'border-primary' : ''}`}>
<div className={`border rounded-lg p-4 ${isPro && !isTeam ? 'border-primary' : ''}`}>
<h4 className="text-md font-semibold">Pro Tier</h4>
<p className="text-sm text-muted-foreground mt-1">For professional users and teams</p>
<ul className="mt-3 space-y-2 text-sm">
<li>• ${isPro ? usageData.limit : 20} of inference credits</li>
<li>• ${isPro && !isTeam ? usageData.limit : 20} of inference credits</li>
<li>• All features included</li>
<li>• Workflow sharing capabilities</li>
</ul>
{isPro && (
{isPro && !isTeam && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
@@ -239,17 +384,75 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
)}
<div className="mt-4">
{isPro ? (
{isPro && !isTeam ? (
<div className="text-sm bg-secondary/50 text-secondary-foreground py-1 px-2 rounded inline-block">
Current Plan
</div>
) : (
<Button
variant="default"
variant={!isPro ? "default" : "outline"}
size="sm"
onClick={handleUpgrade}
onClick={() => handleUpgrade('pro')}
disabled={isUpgrading}
>
Upgrade
{isUpgrading ? <ButtonSkeleton /> : (
<span>{!isPro ? "Upgrade" : "Switch"}</span>
)}
</Button>
)}
</div>
</div>
{/* Team Tier */}
<div className={`border rounded-lg p-4 ${isTeam ? 'border-primary' : ''}`}>
<h4 className="text-md font-semibold">Team Tier</h4>
<p className="text-sm text-muted-foreground mt-1">For collaborative teams</p>
<ul className="mt-3 space-y-2 text-sm">
<li>• $40 of inference credits per seat</li>
<li>• All Pro features included</li>
<li>• Real-time multiplayer collaboration</li>
<li>• Shared workspace for team members</li>
</ul>
{isTeam && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {(subscriptionData?.seats || 1) * 40}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: ''
}`}
/>
<div className="flex justify-between text-xs mt-2">
<span>Team Size</span>
<span>{subscriptionData?.seats || 1} {subscriptionData?.seats === 1 ? 'seat' : 'seats'}</span>
</div>
</div>
)}
<div className="mt-4">
{isTeam ? (
<div className="text-sm bg-secondary/50 text-secondary-foreground py-1 px-2 rounded inline-block">
Current Plan
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={handleTeamUpgrade}
>
Upgrade to Team
</Button>
)}
</div>
@@ -306,16 +509,149 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
onClick={handleCancel}
disabled={isCanceling}
>
{isCanceling && <LoadingAgent size="sm" />}
<span className={isCanceling ? "ml-2" : ""}>Manage Subscription</span>
{isCanceling ? <ButtonSkeleton /> : (
<span>Manage Subscription</span>
)}
</Button>
</div>
)}
</div>
</div>
)}
<Dialog open={isTeamDialogOpen} onOpenChange={setIsTeamDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Team Subscription</DialogTitle>
<DialogDescription>
Set up a team workspace with collaborative features. Each seat costs $40/month and gets $40 of inference credits.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="seats">Number of seats</Label>
<Select
value={seats.toString()}
onValueChange={(value) => setSeats(parseInt(value))}
>
<SelectTrigger id="seats">
<SelectValue placeholder="Select number of seats" />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num} {num === 1 ? 'seat' : 'seats'} (${num * 40}/month)
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-2 text-sm text-muted-foreground">
Your team will have {seats} {seats === 1 ? 'seat' : 'seats'} with a total of ${seats * 40} inference credits per month.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsTeamDialogOpen(false)}
disabled={isUpgradingTeam}
>
Cancel
</Button>
<Button
onClick={confirmTeamUpgrade}
disabled={isUpgradingTeam}
>
{isUpgradingTeam ? <ButtonSkeleton /> : (
<span>Upgrade to Team Plan</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
)
}
}
// Skeleton component for subscription loading state
function SubscriptionSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Free Tier Skeleton */}
<div className="border rounded-lg p-4">
<Skeleton className="h-5 w-24 mb-2" />
<Skeleton className="h-4 w-48 mb-4" />
<div className="space-y-2 mt-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-44" />
</div>
<div className="mt-4">
<Skeleton className="h-9 w-24" />
</div>
</div>
{/* Pro Tier Skeleton */}
<div className="border rounded-lg p-4">
<Skeleton className="h-5 w-24 mb-2" />
<Skeleton className="h-4 w-48 mb-4" />
<div className="space-y-2 mt-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-44" />
</div>
<div className="mt-4">
<Skeleton className="h-9 w-24" />
</div>
</div>
{/* Team Tier Skeleton */}
<div className="border rounded-lg p-4">
<Skeleton className="h-5 w-24 mb-2" />
<Skeleton className="h-4 w-48 mb-4" />
<div className="space-y-2 mt-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-44" />
<Skeleton className="h-4 w-48" />
</div>
<div className="mt-4">
<Skeleton className="h-9 w-32" />
</div>
</div>
{/* Enterprise Tier Skeleton */}
<div className="border rounded-lg p-4 col-span-full">
<Skeleton className="h-5 w-24 mb-2" />
<Skeleton className="h-4 w-48 mb-4" />
<div className="space-y-2 mt-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-44" />
<Skeleton className="h-4 w-48" />
</div>
<div className="mt-4">
<Skeleton className="h-9 w-24" />
</div>
</div>
</div>
</div>
)
}
// Skeleton component for loading state in buttons
function ButtonSkeleton() {
return <Skeleton className="h-9 w-24" />
}
@@ -13,16 +13,21 @@ import { EnvironmentVariables } from './components/environment/environment'
import { General } from './components/general/general'
import { Subscription } from './components/subscription/subscription'
import { SettingsNavigation } from './components/settings-navigation/settings-navigation'
import { TeamManagement } from './components/team-management/team-management'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('SettingsModal')
interface SettingsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription'
type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const [isTeam, setIsTeam] = useState(false)
// Listen for the custom event to open the settings modal with a specific tab
useEffect(() => {
@@ -40,6 +45,25 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [onOpenChange])
// Check if user is on team plan
useEffect(() => {
async function checkTeamPlan() {
try {
const response = await fetch('/api/user/subscription')
if (response.ok) {
const data = await response.json()
setIsTeam(data.isTeam)
}
} catch (error) {
logger.error('Error checking team plan:', error)
}
}
if (open) {
checkTeamPlan()
}
}, [open])
// Check if subscriptions are enabled
const isSubscriptionEnabled = !!client.subscription
@@ -89,6 +113,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<Subscription onOpenChange={onOpenChange} />
</div>
)}
{isTeam && (
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>
)}
</div>
</div>
</DialogContent>
+103
View File
@@ -0,0 +1,103 @@
import * as React from 'react'
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
interface InvitationEmailProps {
inviterName?: string
organizationName?: string
inviteLink?: string
invitedEmail?: string
updatedDate?: Date
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
export const InvitationEmail = ({
inviterName = 'A team member',
organizationName = 'an organization',
inviteLink = '',
invitedEmail = '',
updatedDate = new Date(),
}: InvitationEmailProps) => {
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>You've been invited to join {organizationName} on Sim Studio</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={`${baseUrl}/static/sim.png`}
width="114"
alt="Sim Studio"
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong> on Sim Studio.
Sim Studio is a powerful, user-friendly platform for building, testing, and optimizing agentic workflows.
</Text>
<Link href={inviteLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<Text style={baseStyles.paragraph}>
This invitation will expire in 48 hours. If you believe this invitation was sent in error,
please ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Studio Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} to {invitedEmail} with an invitation
to join {organizationName} on Sim Studio.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default InvitationEmail
+22
View File
@@ -3,6 +3,7 @@ import { OTPVerificationEmail } from './otp-verification-email'
import { ResetPasswordEmail } from './reset-password-email'
import { WaitlistApprovalEmail } from './waitlist-approval-email'
import { WaitlistConfirmationEmail } from './waitlist-confirmation-email'
import { InvitationEmail } from './invitation-email'
/**
* Renders the OTP verification email to HTML
@@ -25,6 +26,24 @@ export async function renderPasswordResetEmail(
return await renderAsync(ResetPasswordEmail({ username, resetLink, updatedDate: new Date() }))
}
/**
* Renders the invitation email to HTML
*/
export async function renderInvitationEmail(
inviterName: string,
organizationName: string,
inviteLink: string,
invitedEmail: string
): Promise<string> {
return await renderAsync(InvitationEmail({
inviterName,
organizationName,
inviteLink,
invitedEmail,
updatedDate: new Date()
}))
}
/**
* Renders the waitlist confirmation email to HTML
*/
@@ -53,6 +72,7 @@ export function getEmailSubject(
| 'reset-password'
| 'waitlist-confirmation'
| 'waitlist-approval'
| 'invitation'
): string {
switch (type) {
case 'sign-in':
@@ -67,6 +87,8 @@ export function getEmailSubject(
return 'Welcome to the Sim Studio Waitlist'
case 'waitlist-approval':
return "You've Been Approved to Join Sim Studio!"
case 'invitation':
return "You've been invited to join a team on Sim Studio"
default:
return 'Sim Studio'
}
@@ -0,0 +1,37 @@
CREATE TABLE "invitation" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"inviter_id" text NOT NULL,
"organization_id" text NOT NULL,
"role" text NOT NULL,
"status" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "member" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"organization_id" text NOT NULL,
"role" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"logo" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN "active_organization_id" text;--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "trial_start" timestamp;--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "trial_end" timestamp;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_active_organization_id_organization_id_fk" FOREIGN KEY ("active_organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1745519847269,
"tag": "0030_happy_joseph",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1745638714702,
"tag": "0031_lively_nico_minoru",
"breakpoints": true
}
]
}
+42 -9
View File
@@ -3,6 +3,7 @@ import {
decimal,
integer,
json,
jsonb,
pgTable,
text,
timestamp,
@@ -31,6 +32,7 @@ export const session = pgTable('session', {
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
activeOrganizationId: text('active_organization_id').references(() => organization.id, { onDelete: 'set null' }),
})
export const account = pgTable('account', {
@@ -226,14 +228,16 @@ export const customTools = pgTable('custom_tools', {
export const subscription = pgTable("subscription", {
id: text('id').primaryKey(),
plan: text('plan').notNull(),
referenceId: text('reference_id').notNull(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
status: text('status'),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
seats: integer('seats')
referenceId: text('reference_id').notNull(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
status: text('status'),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
seats: integer('seats'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end')
});
export const chat = pgTable('chat', {
@@ -268,4 +272,33 @@ export const chat = pgTable('chat', {
subdomainIdx: uniqueIndex('subdomain_idx').on(table.subdomain),
}
}
)
)
export const organization = pgTable("organization", {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
logo: text('logo'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const member = pgTable("member", {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const invitation = pgTable("invitation", {
id: text('id').primaryKey(),
email: text('email').notNull(),
inviterId: text('inviter_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
status: text('status').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
+5 -2
View File
@@ -1,5 +1,6 @@
import { emailOTPClient, genericOAuthClient } from 'better-auth/client/plugins'
import { stripeClient } from '@better-auth/stripe/client'
import { organizationClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import { isProd } from '@/lib/environment'
@@ -22,17 +23,19 @@ export function getBaseURL() {
export const client = createAuthClient({
baseURL: getBaseURL(),
plugins: [
genericOAuthClient(),
emailOTPClient(),
genericOAuthClient(),
// Only include Stripe client in production
...(isProd ? [
stripeClient({
subscription: true // Enable subscription management
})
] : []),
organizationClient(),
],
})
export const { useSession } = client
export const { useSession, useActiveOrganization } = client
export const useSubscription = () => {
// In development, provide mock implementations
+204 -36
View File
@@ -2,7 +2,7 @@ import { headers } from 'next/headers'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { emailOTP, genericOAuth } from 'better-auth/plugins'
import { emailOTP, genericOAuth, organization } from 'better-auth/plugins'
import { stripe } from '@better-auth/stripe'
import Stripe from 'stripe'
import { Resend } from 'resend'
@@ -10,10 +10,12 @@ import {
getEmailSubject,
renderOTPEmail,
renderPasswordResetEmail,
renderInvitationEmail,
} from '@/components/emails/render-email'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { eq, and } from 'drizzle-orm'
const logger = createLogger('Auth')
@@ -63,6 +65,41 @@ export const auth = betterAuth({
updateAge: 24 * 60 * 60, // 24 hours (how often to refresh the expiry)
freshAge: 60 * 60, // 1 hour (or set to 0 to disable completely)
},
databaseHooks: {
session: {
create: {
before: async (session) => {
try {
// Find the first organization this user is a member of
const members = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, session.userId))
.limit(1);
if (members.length > 0) {
logger.info('Found organization for user', {
userId: session.userId,
organizationId: members[0].organizationId
});
return {
data: {
...session,
activeOrganizationId: members[0].organizationId
}
};
} else {
logger.info('No organizations found for user', { userId: session.userId });
return { data: session };
}
} catch (error) {
logger.error('Error setting active organization', { error, userId: session.userId });
return { data: session };
}
}
}
}
},
account: {
accountLinking: {
enabled: true,
@@ -647,6 +684,8 @@ export const auth = betterAuth({
limits: {
cost: process.env.FREE_TIER_COST_LIMIT ? parseInt(process.env.FREE_TIER_COST_LIMIT) : 5,
sharingEnabled: 0,
multiplayerEnabled: 0,
workspaceCollaborationEnabled: 0
}
},
{
@@ -655,16 +694,75 @@ export const auth = betterAuth({
limits: {
cost: process.env.PRO_TIER_COST_LIMIT ? parseInt(process.env.PRO_TIER_COST_LIMIT) : 20,
sharingEnabled: 1,
multiplayerEnabled: 0,
workspaceCollaborationEnabled: 0
}
},
{
name: 'team',
priceId: process.env.STRIPE_TEAM_PRICE_ID || '',
limits: {
cost: process.env.TEAM_TIER_COST_LIMIT ? parseInt(process.env.TEAM_TIER_COST_LIMIT) : 40, // $40 per seat
sharingEnabled: 1,
multiplayerEnabled: 1,
workspaceCollaborationEnabled: 1
}
}
],
onSubscriptionCreate: async ({
authorizeReference: async ({ user, referenceId, action }) => {
// User can always manage their own subscriptions
if (referenceId === user.id) {
return true
}
// Check if referenceId is an organizationId the user has admin rights to
const members = await db.select()
.from(schema.member)
.where(
and(
eq(schema.member.userId, user.id),
eq(schema.member.organizationId, referenceId)
)
)
const member = members[0]
// Allow if the user is an owner or admin of the organization
return member?.role === 'owner' || member?.role === 'admin'
},
getCheckoutSessionParams: async ({ user, plan, subscription }, request) => {
if (plan.name === 'team') {
return {
params: {
allow_promotion_codes: true,
line_items: [
{
price: plan.priceId,
quantity: subscription?.seats || 1,
adjustable_quantity: {
enabled: true,
minimum: 1,
maximum: 50
}
}
]
}
}
}
return {
params: {
allow_promotion_codes: true
}
}
},
onSubscriptionComplete: async ({
event,
stripeSubscription,
subscription
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
logger.info('Subscription created', {
@@ -674,29 +772,25 @@ export const auth = betterAuth({
status: subscription.status
})
},
onSubscriptionUpdated: async ({
subscription,
previousStatus,
user
}: {
subscription: any
previousStatus: string
user: any
}, request?: any) => {
onSubscriptionUpdate: async ({
event,
subscription
}: {
event: Stripe.Event
subscription: any
}) => {
logger.info('Subscription updated', {
subscriptionId: subscription.id,
userId: user.id,
previousStatus,
newStatus: subscription.status
subscriptionId: subscription.id,
status: subscription.status
})
},
onSubscriptionDeleted: async ({
event,
stripeSubscription,
subscription
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
logger.info('Subscription deleted', {
@@ -704,22 +798,96 @@ export const auth = betterAuth({
referenceId: subscription.referenceId
})
},
onEvent: async (event: any) => {
logger.info("Stripe webhook hit")
logger.info('Stripe webhook event received', {
type: event.type,
id: event.id
},
}),
// Add organization plugin as a separate entry in the plugins array
organization({
// Allow team plan subscribers to create organizations
allowUserToCreateOrganization: async (user) => {
// Get subscription data
const dbSubscriptions = await db.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, user.id))
// Check if user has active team subscription
const hasTeamPlan = dbSubscriptions.some(
sub => (sub.status === 'active') && sub.plan === 'team'
)
return hasTeamPlan
},
// Set a fixed membership limit of 50, but the actual limit will be enforced in the invitation flow
membershipLimit: 50,
// Validate seat limits before sending invitations
beforeInvite: async ({ organization }: { organization: { id: string } }) => {
// Get subscription for this organization
const subscriptions = await db.select()
.from(schema.subscription)
.where(
and(
eq(schema.subscription.referenceId, organization.id),
eq(schema.subscription.status, 'active')
)
)
const teamSubscription = subscriptions.find(sub => sub.plan === 'team')
if (!teamSubscription) {
throw new Error('No active team subscription for this organization')
}
// Count current members + pending invitations
const members = await db.select()
.from(schema.member)
.where(eq(schema.member.organizationId, organization.id))
const pendingInvites = await db.select()
.from(schema.invitation)
.where(
and(
eq(schema.invitation.organizationId, organization.id),
eq(schema.invitation.status, 'pending')
)
)
const totalCount = members.length + pendingInvites.length
const seatLimit = teamSubscription.seats || 1
if (totalCount >= seatLimit) {
throw new Error(`Organization has reached its seat limit of ${seatLimit}`)
}
},
sendInvitationEmail: async (data: any) => {
try {
const { invitation, organization, inviter } = data
const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/${invitation.id}`
const inviterName = inviter.user?.name || 'A team member'
const html = await renderInvitationEmail(
inviterName,
organization.name,
inviteUrl,
invitation.email
)
await resend.emails.send({
from: 'Sim Studio <team@simstudio.ai>',
to: invitation.email,
subject: `${inviterName} has invited you to join ${organization.name} on Sim Studio`,
html,
})
switch (event.type) {
case 'customer.subscription.created':
logger.info('Subscription creation event details', {
subscription: event.data.object,
customerId: event.data.object.customer
})
break
}
},
} catch (error) {
logger.error('Error sending invitation email', { error })
}
},
organizationCreation: {
afterCreate: async ({ organization, member, user }) => {
logger.info('Organization created', {
organizationId: organization.id,
creatorId: user.id
})
}
},
})
] : []),
+252 -28
View File
@@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { client } from './auth-client'
import { createLogger } from './logs/console-logger'
import { createLogger } from '@/lib/logs/console-logger'
import { isProd } from '@/lib/environment'
const logger = createLogger('Subscription')
@@ -17,22 +17,101 @@ export async function isProPlan(userId: string): Promise<boolean> {
return true
}
const dbSubscriptions = await db.select()
// First check organizations the user belongs to (prioritize org subscriptions)
const memberships = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for active Pro or Team subscriptions
for (const membership of memberships) {
const orgSubscriptions = await db.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, membership.organizationId))
const orgHasProPlan = orgSubscriptions.some(
sub => (sub.status === 'active') && (sub.plan === 'pro' || sub.plan === 'team')
)
if (orgHasProPlan) {
logger.info('User has pro plan via organization', { userId, orgId: membership.organizationId })
return true
}
}
// If no org subscriptions, check direct subscriptions
const directSubscriptions = await db.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, userId))
// Find active pro subscription
const activeSubscription = dbSubscriptions.find(
sub => (sub.status === 'active') && sub.plan === 'pro'
// Find active pro subscription (either Pro or Team plan)
const hasDirectProPlan = directSubscriptions.some(
sub => (sub.status === 'active') && (sub.plan === 'pro' || sub.plan === 'team')
)
return !!activeSubscription
if (hasDirectProPlan) {
logger.info('User has direct pro plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
/**
* Check if the user is on the Team plan
*/
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
// In development, enable Team features for easier testing
if (!isProd) {
return true
}
// First check organizations the user belongs to (prioritize org subscriptions)
const memberships = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for active Team subscriptions
for (const membership of memberships) {
const orgSubscriptions = await db.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, membership.organizationId))
const orgHasTeamPlan = orgSubscriptions.some(
sub => (sub.status === 'active') && sub.plan === 'team'
)
if (orgHasTeamPlan) {
return true
}
}
// If no org subscriptions found, check direct subscriptions
const directSubscriptions = await db.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, userId))
// Find active team subscription
const hasDirectTeamPlan = directSubscriptions.some(
sub => (sub.status === 'active') && sub.plan === 'team'
)
if (hasDirectTeamPlan) {
logger.info('User has direct team plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
/**
* Check if a user has exceeded their cost limit based on their subscription plan
*/
@@ -43,30 +122,53 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
return false
}
// Get user's subscription
const { data: subscriptions } = await client.subscription.list({
// Get user's direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId }
})
// Find active subscription
const activeSubscription = subscriptions?.find(
// Find active direct subscription
const activeDirectSubscription = directSubscriptions?.find(
sub => sub.status === 'active'
)
// Get configured limits from environment variables or subscription
let costLimit: number
// Get organizations the user belongs to
const memberships = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
if (activeSubscription && typeof activeSubscription.limits?.cost === 'number') {
// Use the limit from the subscription
costLimit = activeSubscription.limits.cost
} else {
// Use default free tier limit
costLimit = process.env.FREE_TIER_COST_LIMIT
let highestCostLimit = 0
// Check cost limit from direct subscription
if (activeDirectSubscription && typeof activeDirectSubscription.limits?.cost === 'number') {
highestCostLimit = activeDirectSubscription.limits.cost
}
// Check cost limits from organization subscriptions
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId }
})
const activeOrgSubscription = orgSubscriptions?.find(
sub => sub.status === 'active'
)
if (activeOrgSubscription &&
typeof activeOrgSubscription.limits?.cost === 'number' &&
activeOrgSubscription.limits.cost > highestCostLimit) {
highestCostLimit = activeOrgSubscription.limits.cost
}
}
// If no subscription found, use default free tier limit
if (highestCostLimit === 0) {
highestCostLimit = process.env.FREE_TIER_COST_LIMIT
? parseFloat(process.env.FREE_TIER_COST_LIMIT)
: 5
}
logger.info('User cost limit from subscription', { userId, costLimit })
logger.info('User cost limit from subscription', { userId, costLimit: highestCostLimit })
// Get user's actual usage from the database
const statsRecords = await db.select().from(schema.userStats).where(eq(schema.userStats.userId, userId))
@@ -79,7 +181,7 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
// Get the current cost and compare with the limit
const currentCost = parseFloat(statsRecords[0].totalCost.toString())
return currentCost >= costLimit
return currentCost >= highestCostLimit
} catch (error) {
logger.error('Error checking cost limit', { error, userId })
return false // Be conservative in case of error
@@ -89,30 +191,152 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
/**
* Check if a user is allowed to share workflows based on their subscription plan
*/
export async function canShareWorkflows(userId: string): Promise<boolean> {
export async function isSharingEnabled(userId: string): Promise<boolean> {
try {
// In development, always allow sharing
if (!isProd) {
return true
}
const { data: subscriptions } = await client.subscription.list({
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId }
})
const activeSubscription = subscriptions?.find(
const activeDirectSubscription = directSubscriptions?.find(
sub => sub.status === 'active'
)
// If no active subscription or subscription is free tier, sharing is not allowed
if (!activeSubscription || activeSubscription.plan === 'free') {
return false
// If user has direct pro/team subscription with sharing enabled
if (activeDirectSubscription && activeDirectSubscription.limits?.sharingEnabled) {
return true
}
// Check if the plan's limits include sharing
return !!activeSubscription.limits?.sharingEnabled
// Check organizations the user belongs to
const memberships = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with sharing enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId }
})
const activeOrgSubscription = orgSubscriptions?.find(
sub => sub.status === 'active'
)
if (activeOrgSubscription && activeOrgSubscription.limits?.sharingEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking sharing permission', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if multiplayer collaboration is enabled for the user
*/
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
try {
// In development, always enable multiplayer
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId }
})
const activeDirectSubscription = directSubscriptions?.find(
sub => sub.status === 'active'
)
// If user has direct team subscription with multiplayer enabled
if (activeDirectSubscription && activeDirectSubscription.limits?.multiplayerEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with multiplayer enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId }
})
const activeOrgSubscription = orgSubscriptions?.find(
sub => sub.status === 'active'
)
if (activeOrgSubscription && activeOrgSubscription.limits?.multiplayerEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking multiplayer permission', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if workspace collaboration is enabled for the user
*/
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
try {
// In development, always enable workspace collaboration
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId }
})
const activeDirectSubscription = directSubscriptions?.find(
sub => sub.status === 'active'
)
// If user has direct team subscription with workspace collaboration enabled
if (activeDirectSubscription && activeDirectSubscription.limits?.workspaceCollaborationEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with workspace collaboration enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId }
})
const activeOrgSubscription = orgSubscriptions?.find(
sub => sub.status === 'active'
)
if (activeOrgSubscription && activeOrgSubscription.limits?.workspaceCollaborationEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking workspace collaboration permission', { error, userId })
return false // Be conservative in case of error
}
}
+120 -77
View File
@@ -1,9 +1,8 @@
import { isProPlan } from './subscription'
import { isProPlan, isTeamPlan } from './subscription'
import { createLogger } from './logs/console-logger'
import { db } from '@/db'
import { eq } from 'drizzle-orm'
import { userStats } from '@/db/schema'
import { client } from './auth-client'
import { userStats, member, organization as organizationTable, subscription } from '@/db/schema'
import { isProd } from '@/lib/environment'
const logger = createLogger('UsageMonitor')
@@ -19,12 +18,74 @@ interface UsageData {
limit: number
}
/**
* Gets the number of seats for a team subscription
* Used to calculate usage limits for team plans
*/
async function getTeamSeats(userId: string): Promise<number> {
try {
// First check if user is part of an organization with a team subscription
const memberships = await db.select()
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (memberships.length > 0) {
const orgId = memberships[0].organizationId
// Check for organization's team subscription
const orgSubscriptions = await db.select()
.from(subscription)
.where(eq(subscription.referenceId, orgId))
const teamSubscription = orgSubscriptions.find(
sub => (sub.status === 'active' && sub.plan === 'team')
)
if (teamSubscription?.seats) {
logger.info('Found organization team subscription with seats', {
userId,
orgId,
seats: teamSubscription.seats
})
return teamSubscription.seats
}
}
// If no organization team subscription, check for personal team subscription
const userSubscriptions = await db.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const teamSubscription = userSubscriptions.find(
sub => (sub.status === 'active' && sub.plan === 'team')
)
if (teamSubscription?.seats) {
logger.info('Found personal team subscription with seats', {
userId,
seats: teamSubscription.seats
})
return teamSubscription.seats
}
// Default to 10 seats if we know they're on a team plan but couldn't get seats info
return 10
} catch (error) {
logger.error('Error getting team seats', { error, userId })
// Default to 10 seats on error
return 10
}
}
/**
* Checks a user's cost usage against their subscription plan limit
* and returns usage information including whether they're approaching the limit
*/
export async function checkUsageStatus(userId: string): Promise<UsageData> {
try {
logger.info('Starting usage status check for user', { userId })
// In development, always return permissive limits
if (!isProd) {
// Get actual usage from the database for display purposes
@@ -33,15 +94,12 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
? parseFloat(statsRecords[0].totalCost.toString())
: 0
// In development, set a very high limit to avoid restrictions
const devLimit = 1000
return {
percentUsed: Math.min(Math.round((currentUsage / devLimit) * 100), 100),
percentUsed: Math.min(Math.round((currentUsage / 1000) * 100), 100),
isWarning: false,
isExceeded: false,
currentUsage,
limit: devLimit
limit: 1000
}
}
@@ -49,35 +107,42 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
// Get user's subscription details
const isPro = await isProPlan(userId)
const isTeam = await isTeamPlan(userId)
// Get the subscription limits
const { data: subscriptions } = await client.subscription.list({
query: { referenceId: userId }
})
logger.info('User subscription status', { userId, isPro, isTeam })
// Find active subscription
const activeSubscription = subscriptions?.find(
sub => sub.status === 'active' || sub.status === 'trialing'
)
// Get configured limits from environment variables or subscription
// Determine the limit based on subscription type
let limit: number
if (activeSubscription && typeof activeSubscription.limits?.cost === 'number') {
// Use the limit from the subscription if available
limit = activeSubscription.limits.cost
if (isTeam) {
// For team plans, get the number of seats and multiply by per-seat limit
const teamSeats = await getTeamSeats(userId)
const perSeatLimit = process.env.TEAM_TIER_COST_LIMIT
? parseFloat(process.env.TEAM_TIER_COST_LIMIT)
: 40
limit = perSeatLimit * teamSeats
logger.info('Using team plan limit', {
userId,
seats: teamSeats,
perSeatLimit,
totalLimit: limit
})
} else if (isPro) {
// Pro plan has a fixed limit
limit = process.env.PRO_TIER_COST_LIMIT
? parseFloat(process.env.PRO_TIER_COST_LIMIT)
: 20
logger.info('Using pro plan limit', { userId, limit })
} else {
// Fallback to environment variables
const freeLimit = process.env.FREE_TIER_COST_LIMIT
// Free tier limit
limit = process.env.FREE_TIER_COST_LIMIT
? parseFloat(process.env.FREE_TIER_COST_LIMIT)
: 5
const proLimit = process.env.PRO_TIER_COST_LIMIT
? parseFloat(process.env.PRO_TIER_COST_LIMIT)
: 50
// Set the appropriate limit based on subscription
limit = isPro ? proLimit : freeLimit
logger.info('Using free tier limit', { userId, limit })
}
// Get actual usage from the database
@@ -85,6 +150,8 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
// If no stats record exists, create a default one
if (statsRecords.length === 0) {
logger.info('No usage stats found for user', { userId, limit })
return {
percentUsed: 0,
isWarning: false,
@@ -104,6 +171,15 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
const isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100
const isExceeded = currentUsage >= limit
logger.info('Final usage statistics', {
userId,
currentUsage,
limit,
percentUsed,
isWarning,
isExceeded
})
return {
percentUsed,
isWarning,
@@ -112,7 +188,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
limit
}
} catch (error) {
logger.error('Error checking usage status', { error, userId })
logger.error('Error checking usage status', {
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
userId
})
// Return default values in case of error
return {
@@ -178,7 +257,6 @@ export async function checkAndNotifyUsage(userId: string): Promise<void> {
}
}
// Add this function to check usage limits on the server-side for API routes
/**
* Server-side function to check if a user has exceeded their usage limits
* For use in API routes, webhooks, and scheduled executions
@@ -204,57 +282,22 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
logger.info('Server-side checking usage limits for user', { userId })
// Get the user's subscription
const { data: subscriptions } = await client.subscription.list({
query: { referenceId: userId }
})
// Find active subscription
const activeSubscription = subscriptions?.find(
sub => sub.status === 'active' || sub.status === 'trialing'
)
// Get configured limits from environment variables or subscription
let costLimit: number
if (activeSubscription && typeof activeSubscription.limits?.cost === 'number') {
// Use the limit from the subscription
costLimit = activeSubscription.limits.cost
} else {
// Use default free tier limit
costLimit = process.env.FREE_TIER_COST_LIMIT
? parseFloat(process.env.FREE_TIER_COST_LIMIT)
: 5
}
logger.info('Server-side user cost limit from subscription', { userId, costLimit })
// Get user's actual usage from the database
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
if (statsRecords.length === 0) {
// No usage yet, so they haven't exceeded the limit
return {
isExceeded: false,
currentUsage: 0,
limit: costLimit
}
}
// Get the current cost and compare with the limit
const currentUsage = parseFloat(statsRecords[0].totalCost.toString())
const isExceeded = currentUsage >= costLimit
// Get usage data using the same function we use for client-side
const usageData = await checkUsageStatus(userId)
return {
isExceeded,
currentUsage,
limit: costLimit,
message: isExceeded
? `Usage limit exceeded: ${currentUsage.toFixed(2)}$ used of ${costLimit}$ limit. Please upgrade your plan to continue.`
isExceeded: usageData.isExceeded,
currentUsage: usageData.currentUsage,
limit: usageData.limit,
message: usageData.isExceeded
? `Usage limit exceeded: ${usageData.currentUsage.toFixed(2)}$ used of ${usageData.limit}$ limit. Please upgrade your plan to continue.`
: undefined
}
} catch (error) {
logger.error('Error in server-side usage limit check', { error, userId })
logger.error('Error in server-side usage limit check', {
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
userId
})
// Be conservative in case of error - allow execution but log the issue
return {
+13
View File
@@ -51,6 +51,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/w/1', request.url))
}
// Allow access to invitation links
if (request.nextUrl.pathname.startsWith('/invite/')) {
return NextResponse.next()
}
// Handle protected routes that require authentication
if (url.pathname.startsWith('/w/') || url.pathname === '/w') {
if (!hasActiveSession) {
@@ -79,6 +84,12 @@ export async function middleware(request: NextRequest) {
// Check for a waitlist token in the URL
const waitlistToken = url.searchParams.get('token')
// If there's a redirect to the invite page, bypass waitlist check
const redirectParam = request.nextUrl.searchParams.get('redirect')
if (redirectParam && redirectParam.startsWith('/invite/')) {
return NextResponse.next()
}
// Validate the token if present
if (waitlistToken) {
try {
@@ -151,12 +162,14 @@ export async function middleware(request: NextRequest) {
return response
}
// Update matcher to include invitation routes
export const config = {
matcher: [
'/w', // Match exactly /w
'/w/:path*', // Match protected routes
'/login',
'/signup',
'/invite/:path*', // Match invitation routes
'/((?!_next/static|_next/image|favicon.ico).*)'
],
}