From 2699a98efd5a0ec6e834c801ab4228f4086094c3 Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:54:55 +0100 Subject: [PATCH] feat(platform): lock AI Center behind the Plus plan like SSO (#15958) --- .../engineering/platform-admin-structure.md | 4 + .../web/public/locales/en/translation.json | 7 +- .../components/sidebar/ap-sidebar-item.tsx | 5 +- .../app/components/sidebar/platform/index.tsx | 2 + .../web/src/app/routes/platform-routes.tsx | 16 +- .../routes/platform/plan-feature-sample.tsx | 13 +- .../setup/ai/capabilities-tab/index.tsx | 30 ++-- .../platform/setup/ai/providers-tab/index.tsx | 159 +++++++----------- .../features/billing/utils/feature-tier.ts | 3 +- .../billing/utils/platform-features.ts | 12 ++ 10 files changed, 125 insertions(+), 126 deletions(-) diff --git a/brain/knowledge/engineering/platform-admin-structure.md b/brain/knowledge/engineering/platform-admin-structure.md index 939600aeeeeb..2bcdee8ea20b 100644 --- a/brain/knowledge/engineering/platform-admin-structure.md +++ b/brain/knowledge/engineering/platform-admin-structure.md @@ -154,6 +154,10 @@ silently. Worker groups is the last page on the old full-page teaser. (`platform-routes.tsx`), and global search (`components/global-search/static-pages.ts`). Nothing checks the three agree. A page whose locked state is the sample overlay also needs a `PlanFeatureSample` entry, or it opens unlocked. +- **Behind the sample overlay, do not hide the page's buttons by plan.** The preview is `inert`, so + hiding them (AI Center's old `allowWrite`) only makes the teaser look empty. The overlay is UI only: + a server gate is a separate `platformMustHaveFeatureEnabled`, and AI Center's write routes have none. + A feature sold on a tier not in `FeatureTier` (AI Center is on Plus) needs that tier added there first. - **Moving or renaming a route needs a `LEGACY_PATHS` row**, or every bookmark, doc link and in-flight Stripe checkout to the old URL lands on a 404. - **Source folders still follow the old grouping** (`routes/platform/security/embed`, diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index de6a9a9c4b4e..c03c90d1cadd 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -3144,5 +3144,10 @@ "{count} pieces, ready to run in every project MCP reaches.": "{count} pieces, ready to run in every project MCP reaches.", "Reset {name}": "Reset {name}", "Chat goes back to using {provider}.": "Chat goes back to using {provider}.", - "The assistant can't do this yet": "The assistant can't do this yet" + "The assistant can't do this yet": "The assistant can't do this yet", + "Unlock AI Center": "Unlock AI Center", + "Bring your own AI provider keys and choose the models that power your automations": "Bring your own AI provider keys and choose the models that power your automations", + "Connect OpenAI, Anthropic, Google and more": "Connect OpenAI, Anthropic, Google and more", + "Pick the default model for each AI capability": "Pick the default model for each AI capability", + "Pay your provider directly, with no model markup": "Pay your provider directly, with no model markup" } diff --git a/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx b/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx index 4d8cf31c6688..c3a2b02af80e 100644 --- a/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx +++ b/packages/web/src/app/components/sidebar/ap-sidebar-item.tsx @@ -139,6 +139,7 @@ export const ApSidebarItem = (item: SidebarItemType) => { {subItems.map((subItem) => { const shut = isSubItemLocked(subItem); + const subItemTier = subItem.tier ?? item.tier; const subItemActive = isRouteActive({ pathname, to: subItem.to, @@ -159,7 +160,7 @@ export const ApSidebarItem = (item: SidebarItemType) => { ? () => captureLockedClick({ path: subItem.to, - tier: subItem.tier, + tier: subItemTier, }) : undefined } @@ -174,7 +175,7 @@ export const ApSidebarItem = (item: SidebarItemType) => { return ( {shut && !isCrowned ? ( - {subButton} + {subButton} ) : ( subButton )} diff --git a/packages/web/src/app/components/sidebar/platform/index.tsx b/packages/web/src/app/components/sidebar/platform/index.tsx index a2280729b3aa..785b99a308a1 100644 --- a/packages/web/src/app/components/sidebar/platform/index.tsx +++ b/packages/web/src/app/components/sidebar/platform/index.tsx @@ -124,6 +124,8 @@ export function PlatformSidebar() { to: '/platform/ai', label: t('AI Center'), icon: SparklesIcon, + locked: !platform.plan.aiProvidersEnabled, + tier: PLATFORM_FEATURES.aiProviders.tier, subItems: edition === ApEdition.COMMUNITY ? undefined diff --git a/packages/web/src/app/routes/platform-routes.tsx b/packages/web/src/app/routes/platform-routes.tsx index 0f385fb036aa..dc60785d8954 100644 --- a/packages/web/src/app/routes/platform-routes.tsx +++ b/packages/web/src/app/routes/platform-routes.tsx @@ -154,9 +154,11 @@ export const platformRoutes = [ - - - + + + + + @@ -168,9 +170,11 @@ export const platformRoutes = [ - - - + + + + + diff --git a/packages/web/src/app/routes/platform/plan-feature-sample.tsx b/packages/web/src/app/routes/platform/plan-feature-sample.tsx index b8be4a317d51..c4572086ddef 100644 --- a/packages/web/src/app/routes/platform/plan-feature-sample.tsx +++ b/packages/web/src/app/routes/platform/plan-feature-sample.tsx @@ -114,6 +114,16 @@ const PLAN_FEATURE_SAMPLES: Record = { tier: 'enterprise', }, }, + aiProviders: { + isLocked: (plan) => !plan.aiProvidersEnabled, + teaser: { + featureKey: 'UNIVERSAL_AI', + title: 'Unlock AI Center', + description: + 'Bring your own AI provider keys and choose the models that power your automations', + tier: 'plus', + }, + }, }; type PlanFeature = @@ -125,7 +135,8 @@ type PlanFeature = | 'apiKeys' | 'secretManagers' | 'auditLogs' - | 'eventStreaming'; + | 'eventStreaming' + | 'aiProviders'; type PlanFeatureSampleSpec = { isLocked: (plan: PlatformWithoutSensitiveData['plan']) => boolean; diff --git a/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx index 1f7208a85f4f..955a47c3824e 100644 --- a/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx @@ -17,7 +17,6 @@ import { aiToolConfigMutations, aiToolConfigQueries, } from '@/features/platform-admin'; -import { platformHooks } from '@/hooks/platform-hooks'; import { cn } from '@/lib/utils'; import { AiCapabilityDialog } from '../../ai-capabilities/ai-capability-dialog'; @@ -34,8 +33,6 @@ export function CapabilitiesTab() { isError: configsFailed, refetch: refetchConfigs, } = aiToolConfigQueries.useAiToolConfigs(); - const { platform } = platformHooks.useCurrentPlatform(); - const allowWrite = platform.plan.aiProvidersEnabled; const { data: providers, isError: providersFailed, @@ -86,7 +83,6 @@ export function CapabilitiesTab() { ? chatProvider : undefined } - allowWrite={allowWrite} onDelete={() => config && remove(config.id)} onSaved={() => refetchConfigs()} /> @@ -103,7 +99,6 @@ function CapabilityCard({ config, providers, chatProviderFallback, - allowWrite, onDelete, onSaved, }: { @@ -111,7 +106,6 @@ function CapabilityCard({ config?: AiToolConfigWithoutSensitiveData; providers: AIProviderWithoutSensitiveData[]; chatProviderFallback?: AIProviderWithoutSensitiveData; - allowWrite: boolean; onDelete: () => void; onSaved: () => void; }) { @@ -160,7 +154,7 @@ function CapabilityCard({ - {config && allowWrite && ( + {config && ( - {allowWrite && ( - - - - )} + + + ); diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx index 9e076aa6153d..05ba0213a392 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx @@ -30,7 +30,6 @@ import { aiProviderQueries, } from '@/features/platform-admin'; import { projectCollectionUtils } from '@/features/projects'; -import { platformHooks } from '@/hooks/platform-hooks'; import { cn } from '@/lib/utils'; import { SectionHeader } from '../components/section-header'; @@ -59,8 +58,6 @@ export function ProvidersTab() { isError: isProvidersError, refetch, } = aiProviderQueries.useAiProviderConfigs(); - const { platform } = platformHooks.useCurrentPlatform(); - const allowWrite = platform.plan.aiProvidersEnabled; const { data: projects } = projectCollectionUtils.useAllPlatformProjects(); const configs = (providers ?? []).filter( (provider) => provider.provider !== AIProviderName.ACTIVEPIECES, @@ -164,7 +161,7 @@ export function ProvidersTab() { const activeInfo = activeConfig ? providerInfoOf({ provider: activeConfig.provider }) : undefined; - if (activeConfig && activeInfo && allowWrite) { + if (activeConfig && activeInfo) { return ( <> - {allowWrite && ( - - )} + {isProvidersError ? ( ) : configs.length === 0 ? ( - + ) : ( <> - {allowWrite && ( - - )} +
{connectedProviders.map((provider) => ( config.provider === provider, )} projects={projects} - allowWrite={allowWrite} onAdd={() => openConnect(provider)} onOpen={openConfig} onDelete={(id) => deleteProvider(id)} @@ -267,7 +255,6 @@ export function ProvidersTab() { openConnect(info.provider)} /> ))} @@ -293,7 +280,6 @@ function ProviderGroup({ provider, configs, projects, - allowWrite, onAdd, onOpen, onDelete, @@ -301,7 +287,6 @@ function ProviderGroup({ provider: AIProviderName; configs: AIProviderWithoutSensitiveData[]; projects: Project[]; - allowWrite: boolean; onAdd: () => void; onOpen: (id: string) => void; onDelete: (id: string) => Promise; @@ -326,12 +311,10 @@ function ProviderGroup({ {t('configurationsCount', { count: configs.length })}

- {allowWrite && ( - - )} +

@@ -344,7 +327,6 @@ function ProviderGroup({ key={config.id} config={config} projects={projects} - allowWrite={allowWrite} onOpen={() => onOpen(config.id)} onDelete={() => onDelete(config.id)} /> @@ -357,13 +339,11 @@ function ProviderGroup({ function ConfigRow({ config, projects, - allowWrite, onOpen, onDelete, }: { config: AIProviderWithoutSensitiveData; projects: Project[]; - allowWrite: boolean; onOpen: () => void; onDelete: () => Promise; }) { @@ -388,19 +368,18 @@ function ConfigRow({ return (

{ - if (allowWrite && (event.key === 'Enter' || event.key === ' ')) { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onOpen(); } }} className={cn( 'group flex items-center gap-4 px-5 py-3 transition-colors', - allowWrite && - 'cursor-pointer hover:bg-muted/50 active:bg-muted focus-visible:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + 'cursor-pointer hover:bg-muted/50 active:bg-muted focus-visible:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', )} >
@@ -441,39 +420,37 @@ function ConfigRow({
)} - {allowWrite && ( -
event.stopPropagation()} - > - - - - - {t('Delete')} - - - { - await onDelete(); - }} - /> -
- )} +
event.stopPropagation()} + > + + + + + {t('Delete')} + + + { + await onDelete(); + }} + /> +
); } @@ -575,10 +552,8 @@ function ChatProviderRow({ function EmptyProviders({ onConnect, - allowWrite, }: { onConnect: (provider?: AIProviderName) => void; - allowWrite: boolean; }) { const recommended = RECOMMENDED_PROVIDERS.map((provider) => SUPPORTED_AI_PROVIDERS.find((info) => info.provider === provider), @@ -603,12 +578,10 @@ function EmptyProviders({ )}

- {allowWrite && ( - - )} +
{recommended.map((info) => ( @@ -617,7 +590,6 @@ function EmptyProviders({ info={info} tagline={recommendedTagline({ provider: info.provider })} recommended - allowWrite={allowWrite} onConnect={() => onConnect(info.provider)} /> ))} @@ -631,7 +603,6 @@ function EmptyProviders({ onConnect(info.provider)} /> ))} @@ -645,13 +616,11 @@ function AvailableProviderCard({ info, tagline, recommended, - allowWrite, onConnect, }: { info: AiProviderInfo; tagline?: string; recommended?: boolean; - allowWrite: boolean; onConnect: () => void; }) { return ( @@ -668,15 +637,13 @@ function AvailableProviderCard({

{tagline}

)}
- {allowWrite && ( - - )} + ); } diff --git a/packages/web/src/features/billing/utils/feature-tier.ts b/packages/web/src/features/billing/utils/feature-tier.ts index 6a3442b8957d..b859b208cc35 100644 --- a/packages/web/src/features/billing/utils/feature-tier.ts +++ b/packages/web/src/features/billing/utils/feature-tier.ts @@ -1,6 +1,7 @@ export const TIER_LABELS: Record = { + plus: 'Plus', team: 'Team', enterprise: 'Enterprise', }; -export type FeatureTier = 'team' | 'enterprise'; +export type FeatureTier = 'plus' | 'team' | 'enterprise'; diff --git a/packages/web/src/features/billing/utils/platform-features.ts b/packages/web/src/features/billing/utils/platform-features.ts index be4e7e213fe1..a329c4f2a2bb 100644 --- a/packages/web/src/features/billing/utils/platform-features.ts +++ b/packages/web/src/features/billing/utils/platform-features.ts @@ -140,6 +140,18 @@ export const PLATFORM_FEATURES = { 'Keep the catalog on-brand and focused', ], }, + aiProviders: { + featureKey: 'UNIVERSAL_AI', + title: 'Unlock AI Center', + description: + 'Bring your own AI provider keys and choose the models that power your automations', + tier: 'plus', + bullets: [ + 'Connect OpenAI, Anthropic, Google and more', + 'Pick the default model for each AI capability', + 'Pay your provider directly, with no model markup', + ], + }, } satisfies Record; export type PlatformFeatureId = keyof typeof PLATFORM_FEATURES;