From 3f84ed9b72ad4d53e0b9915c4461bbf9e7881fc9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 4 Dec 2025 14:10:50 -0800 Subject: [PATCH 01/19] fix(settings): fix long description on wordpress integration (#2195) --- apps/sim/lib/oauth/oauth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 4379b159579..d53625138ea 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -866,8 +866,8 @@ export const OAUTH_PROVIDERS: Record = { services: { wordpress: { id: 'wordpress', - name: 'WordPress.com', - description: 'Manage posts, pages, media, comments, and more on WordPress.com sites.', + name: 'WordPress', + description: 'Manage posts, pages, media, comments, and more on WordPress sites.', providerId: 'wordpress', icon: (props) => WordpressIcon(props), baseProviderIcon: (props) => WordpressIcon(props), From dc5a2b1ad1d0b755dd8ad83559c9a6de47e2b4f8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 4 Dec 2025 14:35:25 -0800 Subject: [PATCH 02/19] fix(envvar): fix envvar dropdown positioning, remove dead code (#2196) --- .../components/short-input/short-input.tsx | 134 +++--------------- 1 file changed, 18 insertions(+), 116 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx index 0a2f6ad988f..81d639ccc63 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx @@ -79,33 +79,29 @@ export function ShortInput({ wandControlRef, hideInternalWand = false, }: ShortInputProps) { - // Local state for immediate UI updates during streaming const [localContent, setLocalContent] = useState('') const [isFocused, setIsFocused] = useState(false) const [copied, setCopied] = useState(false) const persistSubBlockValueRef = useRef<(value: string) => void>(() => {}) - // Always call the hook - hooks must be called unconditionally + const justPastedRef = useRef(false) + const webhookManagement = useWebhookManagement({ blockId, triggerId: undefined, isPreview, }) - // Wand functionality - always call the hook unconditionally const wandHook = useWand({ wandConfig: config.wandConfig, currentValue: localContent, onStreamStart: () => { - // Clear the content when streaming starts setLocalContent('') }, onStreamChunk: (chunk) => { - // Update local content with each chunk as it arrives setLocalContent((current) => current + chunk) }, onGeneratedContent: (content) => { - // Final content update setLocalContent(content) if (!isPreview && !disabled && !readOnly) { persistSubBlockValueRef.current(content) @@ -123,23 +119,18 @@ export function ShortInput({ } }, [setSubBlockValue]) - // Check if wand is actually enabled const isWandEnabled = config.wandConfig?.enabled ?? false - const inputRef = useRef(null) const overlayRef = useRef(null) - // Get ReactFlow instance for zoom control const reactFlowInstance = useReactFlow() const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - // Check if this input is API key related - memoized to prevent recalculation const isApiKeyField = useMemo(() => { const normalizedId = config?.id?.replace(/\s+/g, '').toLowerCase() || '' const normalizedTitle = config?.title?.replace(/\s+/g, '').toLowerCase() || '' - // Check for common API key naming patterns const apiKeyPatterns = [ 'apikey', 'api_key', @@ -173,11 +164,23 @@ export function ShortInput({ event: 'change' | 'focus' | 'deleteAll' }) => { if (!isApiKeyField || isPreview || disabled || readOnly) return { show: false } + + if (justPastedRef.current) { + return { show: false } + } + if (event === 'focus') { + if (value.length > 20 && !value.includes('{{')) { + return { show: false } + } return { show: true, searchTerm: '' } } if (event === 'change') { - // For API key fields, show env vars while typing without requiring '{{' + const looksLikeRawApiKey = + value.length > 30 && !value.includes('{{') && !value.match(/^[A-Z_][A-Z0-9_]*$/i) + if (looksLikeRawApiKey) { + return { show: false } + } return { show: true, searchTerm: value } } if (event === 'deleteAll') { @@ -188,17 +191,13 @@ export function ShortInput({ [isApiKeyField, isPreview, disabled, readOnly] ) - // Use preview value when in preview mode, otherwise use store value or prop value const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : undefined - // During streaming, use local content; otherwise use base value - // Only use webhook URL when useWebhookUrl flag is true const effectiveValue = useWebhookUrl && webhookManagement.webhookUrl ? webhookManagement.webhookUrl : baseValue const value = wandHook?.isStreaming ? localContent : effectiveValue - // Sync local content with base value when not streaming useEffect(() => { if (!wandHook.isStreaming) { const baseValueString = baseValue?.toString() ?? '' @@ -208,108 +207,41 @@ export function ShortInput({ } }, [baseValue, wandHook.isStreaming, localContent]) - /** - * Scrolls the input to show the cursor position - * Uses canvas for efficient text width measurement instead of DOM manipulation - */ - const scrollToCursor = useCallback(() => { - if (!inputRef.current) return - - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - if (!inputRef.current) return - - const cursorPos = inputRef.current.selectionStart ?? 0 - const inputWidth = inputRef.current.offsetWidth - const scrollWidth = inputRef.current.scrollWidth - - // Get approximate cursor position in pixels using canvas (more efficient) - const textBeforeCursor = inputRef.current.value.substring(0, cursorPos) - const computedStyle = window.getComputedStyle(inputRef.current) - - // Use canvas context for text measurement (more efficient than creating DOM elements) - const canvas = document.createElement('canvas') - const context = canvas.getContext('2d') - if (context) { - context.font = computedStyle.font - const cursorPixelPos = context.measureText(textBeforeCursor).width - - // Calculate optimal scroll position to center the cursor - const targetScroll = Math.max(0, cursorPixelPos - inputWidth / 2) - - // Only scroll if cursor is not visible - if ( - cursorPixelPos < inputRef.current.scrollLeft || - cursorPixelPos > inputRef.current.scrollLeft + inputWidth - ) { - inputRef.current.scrollLeft = Math.min(targetScroll, scrollWidth - inputWidth) - } - - // Sync overlay scroll - if (overlayRef.current) { - overlayRef.current.scrollLeft = inputRef.current.scrollLeft - } - } - }) - }, []) - - // Sync scroll position between input and overlay const handleScroll = useCallback((e: React.UIEvent) => { if (overlayRef.current) { overlayRef.current.scrollLeft = e.currentTarget.scrollLeft } }, []) - // Remove the auto-scroll effect that forces cursor position and replace with natural scrolling - useEffect(() => { - if (inputRef.current && overlayRef.current) { - overlayRef.current.scrollLeft = inputRef.current.scrollLeft - } - }, [value]) - - // Handle paste events to ensure long values are handled correctly const handlePaste = useCallback((_e: React.ClipboardEvent) => { - // Let the paste happen normally - // Then ensure scroll positions are synced after the content is updated + justPastedRef.current = true setTimeout(() => { - if (inputRef.current && overlayRef.current) { - overlayRef.current.scrollLeft = inputRef.current.scrollLeft - } - }, 0) + justPastedRef.current = false + }, 100) }, []) - // Handle wheel events to control ReactFlow zoom const handleWheel = useCallback( (e: React.WheelEvent) => { - // Only handle zoom when Ctrl/Cmd key is pressed if (e.ctrlKey || e.metaKey) { e.preventDefault() e.stopPropagation() - // Get current zoom level and viewport const currentZoom = reactFlowInstance.getZoom() const { x: viewportX, y: viewportY } = reactFlowInstance.getViewport() - // Calculate zoom factor based on wheel delta - // Use a smaller factor for smoother zooming that matches ReactFlow's native behavior const delta = e.deltaY > 0 ? 1 : -1 - // Using 0.98 instead of 0.95 makes the zoom much slower and more gradual const zoomFactor = 0.96 ** delta - // Calculate new zoom level with min/max constraints const newZoom = Math.min(Math.max(currentZoom * zoomFactor, 0.1), 1) - // Get the position of the cursor in the page const { x: pointerX, y: pointerY } = reactFlowInstance.screenToFlowPosition({ x: e.clientX, y: e.clientY, }) - // Calculate the new viewport position to keep the cursor position fixed const newViewportX = viewportX + (pointerX * currentZoom - pointerX * newZoom) const newViewportY = viewportY + (pointerY * currentZoom - pointerY * newZoom) - // Set the new viewport with the calculated position and zoom reactFlowInstance.setViewport( { x: newViewportX, @@ -322,8 +254,6 @@ export function ShortInput({ return false } - // For regular scrolling (without Ctrl/Cmd), let the default behavior happen - // Don't interfere with normal scrolling return true }, [reactFlowInstance] @@ -341,33 +271,6 @@ export function ShortInput({ } }, [useWebhookUrl, webhookManagement?.webhookUrl, value]) - // Value display logic - memoize to avoid unnecessary string operations - const displayValue = useMemo( - () => - password && !isFocused - ? '•'.repeat(value?.toString().length ?? 0) - : (value?.toString() ?? ''), - [password, isFocused, value] - ) - - // Memoize formatted text to avoid recalculation on every render - const formattedText = useMemo(() => { - const textValue = value?.toString() ?? '' - if (password && !isFocused) { - return '•'.repeat(textValue.length) - } - return formatDisplayText(textValue, { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - }) - }, [value, password, isFocused, accessiblePrefixes]) - - // Memoize focus handler to prevent unnecessary re-renders - const handleFocus = useCallback(() => { - setIsFocused(true) - }, []) - - // Memoize blur handler to prevent unnecessary re-renders const handleBlur = useCallback(() => { setIsFocused(false) }, []) @@ -422,7 +325,6 @@ export function ShortInput({ onDragOver, onFocus, }) => { - // Use controller's value for input, but apply local transformations const actualValue = wandHook.isStreaming ? localContent : useWebhookUrl && webhookManagement.webhookUrl From ca3eb5b5a57839481f310d662505496b46b3bc63 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 4 Dec 2025 15:12:50 -0800 Subject: [PATCH 03/19] fix(subscription): fixed text clipping on subscription panel (#2198) --- .../subscription/components/usage-limit/usage-limit.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx index 0ce65973e7e..3de8fedcec2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx @@ -169,6 +169,8 @@ export const UsageLimit = forwardRef( } } + const inputWidthCh = Math.max(3, inputValue.length + 1) + return (
{isEditing ? ( @@ -200,7 +202,7 @@ export const UsageLimit = forwardRef( autoCorrect='off' autoCapitalize='off' spellCheck='false' - style={{ width: `${Math.max(3, inputValue.length)}ch` }} + style={{ width: `${inputWidthCh}ch` }} /> ) : ( From 8e7d8c93e3b0796792d210c6ed373d7e7c14941c Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 4 Dec 2025 15:46:10 -0800 Subject: [PATCH 04/19] fix(profile-pics): remove sharp dependency for serving profile pics in settings (#2199) --- .../components-new/settings-modal/components/general/general.tsx | 1 + .../components/template-profile/template-profile.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx index bf7f7fdf685..bbd8485737d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx @@ -291,6 +291,7 @@ export function General({ onOpenChange }: GeneralProps) { alt={profile?.name || 'User'} width={36} height={36} + unoptimized className={`h-full w-full object-cover transition-opacity duration-300 ${ isUploadingProfilePicture ? 'opacity-50' : 'opacity-100' }`} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/template-profile/template-profile.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/template-profile/template-profile.tsx index c93feea8e33..639ac42ef2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/template-profile/template-profile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/template-profile/template-profile.tsx @@ -348,6 +348,7 @@ export function TemplateProfile() { alt={formData.name || 'Profile picture'} width={36} height={36} + unoptimized className={`h-full w-full object-cover transition-opacity duration-300 ${ isUploadingProfilePicture ? 'opacity-50' : 'opacity-100' }`} From d22b5783bed36ef5a92412ab60d5b7a0ac764b68 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 4 Dec 2025 16:22:29 -0800 Subject: [PATCH 05/19] fix(enterprise-plan): seats should be taken from metadata (#2200) * fix(enterprise): seats need to be picked up from metadata not column * fix env var access * fix user avatar --- .../components/subscription/subscription.tsx | 4 +- .../team-seats-overview.tsx | 9 +-- .../components/team-seats/team-seats.tsx | 3 +- .../components/team-usage/team-usage.tsx | 8 +-- .../team-management/team-management.tsx | 57 ++++++++++--------- .../components/user-avatar/user-avatar.tsx | 2 +- .../lib/billing/calculations/usage-monitor.ts | 2 +- apps/sim/lib/billing/client/utils.ts | 3 - apps/sim/lib/billing/core/billing.ts | 4 +- apps/sim/lib/billing/core/organization.ts | 18 +++--- apps/sim/lib/billing/core/usage.ts | 8 +-- apps/sim/lib/billing/subscriptions/utils.ts | 33 +++++++++++ apps/sim/lib/billing/threshold-billing.ts | 2 +- .../lib/billing/validation/seat-management.ts | 10 ++-- apps/sim/lib/billing/webhooks/enterprise.ts | 4 +- 15 files changed, 101 insertions(+), 66 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx index 247603144de..1d8d4afe946 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx @@ -197,7 +197,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { subscriptionData?.data?.status === 'active', plan: subscriptionData?.data?.plan || 'free', status: subscriptionData?.data?.status || 'inactive', - seats: subscriptionData?.data?.seats || 1, + seats: organizationBillingData?.totalSeats ?? 0, } const usage = { @@ -373,7 +373,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { onBadgeClick={handleBadgeClick} seatsText={ permissions.canManageTeam || subscription.isEnterprise - ? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats` + ? `${subscription.seats} seats` : undefined } current={ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx index b2b2e8d2533..283adeb7499 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -3,23 +3,20 @@ import { Skeleton } from '@/components/ui/skeleton' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' import { cn } from '@/lib/core/utils/cn' -const PILL_COUNT = 8 - type Subscription = { id: string plan: string status: string - seats?: number referenceId: string cancelAtPeriodEnd?: boolean periodEnd?: number | Date trialEnd?: number | Date - metadata?: any } interface TeamSeatsOverviewProps { subscriptionData: Subscription | null isLoadingSubscription: boolean + totalSeats: number usedSeats: number isLoading: boolean onConfirmTeamUpgrade: (seats: number) => Promise @@ -55,6 +52,7 @@ function TeamSeatsSkeleton() { export function TeamSeatsOverview({ subscriptionData, isLoadingSubscription, + totalSeats, usedSeats, isLoading, onConfirmTeamUpgrade, @@ -78,7 +76,7 @@ export function TeamSeatsOverview({
) } diff --git a/apps/sim/components/user-avatar/user-avatar.tsx b/apps/sim/components/user-avatar/user-avatar.tsx index 1c57332e93d..e26b9f58a71 100644 --- a/apps/sim/components/user-avatar/user-avatar.tsx +++ b/apps/sim/components/user-avatar/user-avatar.tsx @@ -55,7 +55,7 @@ export function UserAvatar({ sizes={`${size}px`} className='object-cover' referrerPolicy='no-referrer' - unoptimized={avatarUrl.startsWith('http')} + unoptimized onError={() => setImageError(true)} /> ) : ( diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 956468d9ad1..c048c41a680 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -115,7 +115,7 @@ export async function checkUsageStatus(userId: string): Promise { const orgSub = await getOrganizationSubscription(org.id) if (orgSub?.seats) { const { basePrice } = getPlanPricing(orgSub.plan) - orgCap = (orgSub.seats || 1) * basePrice + orgCap = (orgSub.seats ?? 0) * basePrice } else { // If no subscription, use team default const { basePrice } = getPlanPricing('team') diff --git a/apps/sim/lib/billing/client/utils.ts b/apps/sim/lib/billing/client/utils.ts index 6ac38e90d6e..5266c46d983 100644 --- a/apps/sim/lib/billing/client/utils.ts +++ b/apps/sim/lib/billing/client/utils.ts @@ -96,9 +96,6 @@ export function isAtLeastTeam(subscriptionData: SubscriptionData | null | undefi return status.isTeam || status.isEnterprise } -/** - * Check if user can upgrade - */ export function canUpgrade(subscriptionData: SubscriptionData | null | undefined): boolean { const status = getSubscriptionStatus(subscriptionData) return status.plan === 'free' || status.plan === 'pro' diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 03fe712c1ad..2c670a1c52e 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -144,7 +144,7 @@ export async function calculateSubscriptionOverage(sub: { const totalUsageWithDeparted = totalTeamUsage + departedUsage const { basePrice } = getPlanPricing(sub.plan) - const baseSubscriptionAmount = (sub.seats || 1) * basePrice + const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice totalOverage = Math.max(0, totalUsageWithDeparted - baseSubscriptionAmount) logger.info('Calculated team overage', { @@ -286,7 +286,7 @@ export async function getSimplifiedBillingSummary( const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan) // Use licensed seats from Stripe as source of truth - const licensedSeats = subscription.seats || 1 + const licensedSeats = subscription.seats ?? 0 const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription let totalCurrentUsage = 0 diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index ed09e3333c5..bf90ad62260 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { member, organization, subscription, user, userStats } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { getPlanPricing } from '@/lib/billing/core/billing' -import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils' +import { getEffectiveSeats, getFreeTierLimit } from '@/lib/billing/subscriptions/utils' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('OrganizationBilling') @@ -133,9 +133,12 @@ export async function getOrganizationBillingData( // Get per-seat pricing for the plan const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan) - // Use Stripe subscription seats as source of truth - // Ensure we always have at least 1 seat (protect against 0 or falsy values) - const licensedSeats = Math.max(subscription.seats || 1, 1) + const licensedSeats = subscription.seats ?? 0 + + // For seat count used in UI (invitations, team management): + // Team: seats column (Stripe quantity) + // Enterprise: metadata.seats (allocated seats, not Stripe quantity which is always 1) + const effectiveSeats = getEffectiveSeats(subscription) // Calculate minimum billing amount let minimumBillingAmount: number @@ -174,9 +177,9 @@ export async function getOrganizationBillingData( organizationName: organizationData.name || '', subscriptionPlan: subscription.plan, subscriptionStatus: subscription.status || 'inactive', - totalSeats: Math.max(subscription.seats || 1, 1), + totalSeats: effectiveSeats, // Uses metadata.seats for enterprise, seats column for team usedSeats: members.length, - seatsCount: licensedSeats, + seatsCount: licensedSeats, // Used for billing calculations (Stripe quantity) totalCurrentUsage: roundCurrency(totalCurrentUsage), totalUsageLimit: roundCurrency(totalUsageLimit), minimumBillingAmount: roundCurrency(minimumBillingAmount), @@ -232,9 +235,8 @@ export async function updateOrganizationUsageLimit( } } - // Team plans have minimum based on seats const { basePrice } = getPlanPricing(subscription.plan) - const minimumLimit = Math.max(subscription.seats || 1, 1) * basePrice + const minimumLimit = (subscription.seats ?? 0) * basePrice // Validate new limit is not below minimum if (newLimit < minimumLimit) { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 0c493cb3ab3..80ec17fff1d 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -95,7 +95,7 @@ export async function getUserUsageData(userId: string): Promise { const { getPlanPricing } = await import('@/lib/billing/core/billing') const { basePrice } = getPlanPricing(subscription.plan) - const minimum = (subscription.seats || 1) * basePrice + const minimum = (subscription.seats ?? 0) * basePrice if (orgData.length > 0 && orgData[0].orgUsageLimit) { const configured = Number.parseFloat(orgData[0].orgUsageLimit) @@ -168,7 +168,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise 0 && orgData[0].orgUsageLimit) { const configured = Number.parseFloat(orgData[0].orgUsageLimit) @@ -361,14 +361,14 @@ export async function getUserUsageLimit(userId: string): Promise { const configured = Number.parseFloat(orgData[0].orgUsageLimit) const { getPlanPricing } = await import('@/lib/billing/core/billing') const { basePrice } = getPlanPricing(subscription.plan) - const minimum = (subscription.seats || 1) * basePrice + const minimum = (subscription.seats ?? 0) * basePrice return Math.max(configured, minimum) } // If org hasn't set a custom limit, use minimum (seats × cost per seat) const { getPlanPricing } = await import('@/lib/billing/core/billing') const { basePrice } = getPlanPricing(subscription.plan) - return (subscription.seats || 1) * basePrice + return (subscription.seats ?? 0) * basePrice } /** diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index e218cef73a9..3a8d1b63844 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -4,6 +4,7 @@ import { DEFAULT_PRO_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT, } from '@/lib/billing/constants' +import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { env } from '@/lib/core/config/env' /** @@ -38,6 +39,38 @@ export function checkEnterprisePlan(subscription: any): boolean { return subscription?.plan === 'enterprise' && subscription?.status === 'active' } +/** + * Type guard to check if metadata is valid EnterpriseSubscriptionMetadata + */ +function isEnterpriseMetadata(metadata: unknown): metadata is EnterpriseSubscriptionMetadata { + return ( + !!metadata && + typeof metadata === 'object' && + 'seats' in metadata && + typeof (metadata as EnterpriseSubscriptionMetadata).seats === 'string' + ) +} + +export function getEffectiveSeats(subscription: any): number { + if (!subscription) { + return 0 + } + + if (subscription.plan === 'enterprise') { + const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | null + if (isEnterpriseMetadata(metadata)) { + return Number.parseInt(metadata.seats, 10) + } + return 0 + } + + if (subscription.plan === 'team') { + return subscription.seats ?? 0 + } + + return 0 +} + export function checkProPlan(subscription: any): boolean { return subscription?.plan === 'pro' && subscription?.status === 'active' } diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 515a4a5077b..40b97aa64ef 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -330,7 +330,7 @@ export async function checkAndBillOrganizationOverageThreshold( } const { basePrice: basePricePerSeat } = getPlanPricing(orgSubscription.plan) - const basePrice = basePricePerSeat * (orgSubscription.seats || 1) + const basePrice = basePricePerSeat * (orgSubscription.seats ?? 0) const currentOverage = Math.max(0, totalTeamUsage - basePrice) const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage) diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 923c08b93f6..9aeb5ef091e 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { invitation, member, organization, subscription, user, userStats } from '@sim/db/schema' import { and, count, eq } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' import { createLogger } from '@/lib/logs/console/logger' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -66,9 +67,9 @@ export async function validateSeatAvailability( const currentSeats = memberCount[0]?.count || 0 // Determine seat limits based on subscription - // Team: seats from Stripe subscription quantity - // Enterprise: seats from metadata (stored in subscription.seats) - const maxSeats = subscription.seats || 1 + // Team: seats from Stripe subscription quantity (seats column) + // Enterprise: seats from metadata.seats (not from seats column which is always 1) + const maxSeats = getEffectiveSeats(subscription) const availableSeats = Math.max(0, maxSeats - currentSeats) const canInvite = availableSeats >= additionalSeats @@ -140,7 +141,8 @@ export async function getOrganizationSeatInfo( const currentSeats = memberCount[0]?.count || 0 - const maxSeats = subscription.seats || 1 + // Team: seats from column, Enterprise: seats from metadata + const maxSeats = getEffectiveSeats(subscription) const canAddSeats = subscription.plan !== 'enterprise' diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index 1f06806d9dc..b3685d613cd 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -115,7 +115,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) { ? new Date(referenceItem.current_period_end * 1000) : null, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null, - seats, + seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1 trialStart: stripeSubscription.trial_start ? new Date(stripeSubscription.trial_start * 1000) : null, @@ -140,7 +140,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) { periodStart: subscriptionRow.periodStart, periodEnd: subscriptionRow.periodEnd, cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd, - seats: subscriptionRow.seats, + seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1 trialStart: subscriptionRow.trialStart, trialEnd: subscriptionRow.trialEnd, metadata: subscriptionRow.metadata, From 1642ed754bc97f7b82ddca214e377490174c5f58 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:10:59 -0800 Subject: [PATCH 06/19] improvement: modal UI (#2202) * fix: trigger-save delete modal * improvement: old modal styling --- .../create-chunk-modal/create-chunk-modal.tsx | 197 +++--- .../delete-chunk-modal/delete-chunk-modal.tsx | 36 +- .../edit-chunk-modal/edit-chunk-modal.tsx | 286 ++++----- .../[workspaceId]/knowledge/[id]/base.tsx | 25 +- .../components/upload-modal/upload-modal.tsx | 78 ++- .../components/create-modal/create-modal.tsx | 591 +++++++++--------- .../schedule-save/schedule-save.tsx | 31 +- .../components/trigger-save/trigger-save.tsx | 29 +- .../w/[workflowId]/components/panel/panel.tsx | 21 +- .../webhook-settings/webhook-settings.tsx | 25 +- .../cancel-subscription.tsx | 54 +- .../remove-member-dialog.tsx | 79 ++- .../knowledge-base-tags.tsx | 55 +- .../workspace-selector/workspace-selector.tsx | 145 ++--- 14 files changed, 776 insertions(+), 876 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx index 3a4449f0103..eb6dc023262 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx @@ -1,9 +1,15 @@ 'use client' import { useRef, useState } from 'react' -import { AlertCircle, Loader2, X } from 'lucide-react' +import { AlertCircle, Loader2 } from 'lucide-react' import { Button, Textarea } from '@/components/emcn' -import { Modal, ModalContent, ModalTitle } from '@/components/emcn/components/modal/modal' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@/components/emcn/components/modal/modal' import { Label } from '@/components/ui/label' import { createLogger } from '@/lib/logs/console/logger' import type { ChunkData, DocumentData } from '@/stores/knowledge/store' @@ -113,132 +119,107 @@ export function CreateChunkModal({ return ( <> - - {/* Modal Header */} -
-
- - Create Chunk - - -
-
- - {/* Modal Body */} -
-
- {/* Scrollable Content */} -
-
-
- {/* Document Info Section */} -
-
-
-

- {document?.filename || 'Unknown Document'} -

-

- Adding chunk to this document -

-
-
- - {/* Error Display */} - {error && ( -
- -

{error}

-
- )} -
- - {/* Content Input Section - Expands to fill space */} -
- -