diff --git a/apps/sim/app/upgrade/page.tsx b/apps/sim/app/upgrade/page.tsx new file mode 100644 index 00000000000..aa9a9263ee0 --- /dev/null +++ b/apps/sim/app/upgrade/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { isUpgradeReason, UPGRADE_REASON_PARAM } from '@/lib/billing/upgrade-reasons' + +/** + * Public upgrade entry, for callers that cannot know a workspace id — a + * self-hosted deployment linking its users to the hosted plans, or an email + * that predates a workspace switch. + * + * Workspace resolution is not repeated here: `/workspace` already owns it, + * including local recency, last-active fallback, stale-session recovery, and + * the no-workspace creation policy. + */ +export default async function UpgradePage({ + searchParams, +}: { + searchParams: Promise> +}) { + const [session, params] = await Promise.all([getSession(), searchParams]) + + const rawReason = params[UPGRADE_REASON_PARAM] + const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason + const reason = isUpgradeReason(reasonValue) ? reasonValue : undefined + + const target = reason + ? `/workspace?redirect=upgrade&${UPGRADE_REASON_PARAM}=${reason}` + : '/workspace?redirect=upgrade' + + // `/workspace` recovers a signed-out visitor by hard-navigating to `/login` + // with no callback, which would drop the upgrade intent — carry it here + // instead, where the destination is still known. + if (!session?.user) { + redirect(`/login?callbackUrl=${encodeURIComponent(target)}`) + } + + redirect(target) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 60227897d94..2d1c01b5441 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -10,14 +10,17 @@ import { ExpandableContent, SecretInput, SecretReveal, + SquareArrowUpRight, Tooltip, toast, } from '@sim/emcn' import { useParams } from 'next/navigation' import { ThinkingLoader } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' +import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { isHosted } from '@/lib/core/config/env-flags' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { resolveOAuthServiceForSlug, @@ -1052,9 +1055,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { getSettingsHref } = useSettingsNavigation() - const settingsPath = getSettingsHref({ section: 'billing' }) const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' - const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id) + + // Self-hosted plan and limit both live on the hosted account, so local + // workspace billing roles say nothing about who may change them. + const href = isHosted + ? getSettingsHref({ section: 'billing' }) + : data.action === 'upgrade_plan' + ? buildHostedUpgradeUrl() + : HOSTED_BILLING_SETTINGS_URL + const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id) const unavailableMessage = hostContext.hostOrganizationId ? 'Contact an organization admin to manage this workspace’s usage limits.' : 'Only the workspace owner can manage this workspace’s usage limits.' @@ -1086,11 +1096,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {

{canManageBilling ? ( {buttonLabel} - + {isHosted ? : } ) : (

diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx index af1e0f80f1e..d86ed09d2a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx @@ -1,15 +1,37 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { + buildHostedUpgradeUrl, + isUpgradeReason, + UPGRADE_REASON_PARAM, +} from '@/lib/billing/upgrade-reasons' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade' export const metadata: Metadata = { title: 'Upgrade' } export default async function UpgradePage({ params, + searchParams, }: { params: Promise<{ workspaceId: string }> + searchParams: Promise> }) { - const { workspaceId } = await params + const [{ workspaceId }, query] = await Promise.all([params, searchParams]) + + // Both are build constants, so resolve them here rather than mounting a page + // whose only job would be to navigate away. + if (!isHosted) { + const rawReason = query[UPGRADE_REASON_PARAM] + const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason + redirect(buildHostedUpgradeUrl(isUpgradeReason(reasonValue) ? reasonValue : undefined)) + } + + if (!isBillingEnabled) { + redirect(`/workspace/${workspaceId}/home`) + } + return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index ce9e0d95ce7..b0d4d436e0a 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -15,7 +15,6 @@ import { import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants' import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { BillingPeriodToggle, @@ -72,23 +71,16 @@ export function Upgrade({ workspaceId }: UpgradeProps) { router.replace(origin ?? `/workspace/${workspaceId}/home`) }, [origin, router, workspaceId]) - // Enterprise manages billing out-of-band, and self-hosted deployments with - // billing disabled have no plans to surface — redirect to home in both cases. + // Enterprise manages billing out-of-band, so there is no plan to pick here. + // The self-hosted and billing-disabled cases are build constants, not reactive + // state — page.tsx resolves those before this ever mounts. useEffect(() => { - if (!isBillingEnabled) { - router.replace(`/workspace/${workspaceId}/home`) - return - } if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) { router.replace(`/workspace/${workspaceId}/home`) } }, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId]) - if ( - !isBillingEnabled || - state.isLoading || - (canManageBilling && state.subscription.isEnterprise) - ) { + if (state.isLoading || (canManageBilling && state.subscription.isEnterprise)) { return null } diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 6137b205d19..c8c9c2e3367 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -11,6 +11,11 @@ import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' +import { + buildUpgradeHref, + isUpgradeReason, + UPGRADE_REASON_PARAM, +} from '@/lib/billing/upgrade-reasons' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace' @@ -79,6 +84,7 @@ export default function WorkspacePage() { const urlParams = new URLSearchParams(window.location.search) const redirectWorkflowId = urlParams.get('redirect_workflow') + const redirectTarget = urlParams.get('redirect') const { workspaces, lastActiveWorkspaceId, creationPolicy } = data @@ -99,6 +105,19 @@ export default function WorkspacePage() { return } + // `?redirect=upgrade` is how a caller that cannot know a workspace id — a + // self-hosted deployment, an email — reaches the plan picker. + if (redirectTarget === 'upgrade') { + const rawReason = urlParams.get(UPGRADE_REASON_PARAM) + const href = buildUpgradeHref( + targetWorkspace.id, + isUpgradeReason(rawReason) ? rawReason : undefined + ) + logger.info(`Redirecting to upgrade: ${targetWorkspace.id}`) + router.replace(href) + return + } + logger.info(`Redirecting to workspace: ${targetWorkspace.id}`) router.replace(`/workspace/${targetWorkspace.id}/home`) }, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router]) diff --git a/apps/sim/lib/billing/upgrade-reasons.test.ts b/apps/sim/lib/billing/upgrade-reasons.test.ts index 5505a576de6..2d5a5b785f4 100644 --- a/apps/sim/lib/billing/upgrade-reasons.test.ts +++ b/apps/sim/lib/billing/upgrade-reasons.test.ts @@ -3,7 +3,9 @@ */ import { describe, expect, it } from 'vitest' import { + buildHostedUpgradeUrl, buildUpgradeHref, + HOSTED_BILLING_SETTINGS_URL, isUpgradeReason, UPGRADE_REASON_COPY, UPGRADE_REASONS, @@ -31,6 +33,12 @@ describe('upgrade-reasons', () => { expect(buildUpgradeHref('ws-1', 'tables')).toBe('/workspace/ws-1/upgrade?reason=tables') }) + it('builds absolute hosted URLs for self-hosted deployments', () => { + expect(buildHostedUpgradeUrl()).toBe('https://www.sim.ai/upgrade') + expect(buildHostedUpgradeUrl('credits')).toBe('https://www.sim.ai/upgrade?reason=credits') + expect(HOSTED_BILLING_SETTINGS_URL).toBe('https://www.sim.ai/account/settings/billing') + }) + it('guards known reasons', () => { expect(isUpgradeReason('storage')).toBe(true) expect(isUpgradeReason('seats')).toBe(true) diff --git a/apps/sim/lib/billing/upgrade-reasons.ts b/apps/sim/lib/billing/upgrade-reasons.ts index 9e8986f1938..7d25d93fe25 100644 --- a/apps/sim/lib/billing/upgrade-reasons.ts +++ b/apps/sim/lib/billing/upgrade-reasons.ts @@ -2,10 +2,11 @@ * Upgrade-reason registry. * * Single source of truth for the language shown when a user is routed to the - * upgrade page after hitting a usage limit. The same copy drives both the - * upgrade-page header and the threshold/limit emails, so the in-app and email - * journeys never drift apart. + * upgrade page after hitting a usage limit, and for where that route points. + * The same copy drives both the upgrade-page header and the threshold/limit + * emails, so the in-app and email journeys never drift apart. */ +import { SITE_URL } from '@/lib/core/utils/urls' /** The limit categories that can route a user to the upgrade page. */ export const UPGRADE_REASONS = ['credits', 'storage', 'tables', 'seats'] as const @@ -86,3 +87,20 @@ export function buildUpgradeHref(workspaceId: string, reason?: UpgradeReason): s const base = `/workspace/${workspaceId}/upgrade` return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base } + +/** + * Absolute upgrade URL on the hosted app. + * + * Self-hosted deployments talk to Chat through a Chat key issued by the user's + * sim.ai account, so their plan and credits live there rather than on the local + * instance. A local workspace id is meaningless on the hosted app, so this + * points at the account-scoped `/upgrade` entry, which resolves the signed-in + * user's own workspace. + */ +export function buildHostedUpgradeUrl(reason?: UpgradeReason): string { + const base = `${SITE_URL}/upgrade` + return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base +} + +/** Account billing settings on the hosted app, for raising a usage limit. */ +export const HOSTED_BILLING_SETTINGS_URL = `${SITE_URL}/account/settings/billing` as const