mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Waleed Latif
parent
41f12d7b04
commit
09bbf1d6ff
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+9
-3
@@ -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) {
|
||||
|
||||
+398
-62
@@ -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" />
|
||||
}
|
||||
+1049
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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).*)'
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user