= ({
className={cn(!hasAnyApp && 'z-10')}
/>
)
- :
+ : canAccessSnippetsAndEvaluation &&
)}
{showSkeleton && }
diff --git a/web/app/components/apps/studio-route-switch.tsx b/web/app/components/apps/studio-route-switch.tsx
index 7aea1cebbd..53f1337d06 100644
--- a/web/app/components/apps/studio-route-switch.tsx
+++ b/web/app/components/apps/studio-route-switch.tsx
@@ -8,12 +8,14 @@ type Props = {
pageType: StudioPageType
appsLabel: string
snippetsLabel: string
+ showSnippets?: boolean
}
const StudioRouteSwitch = ({
pageType,
appsLabel,
snippetsLabel,
+ showSnippets = true,
}: Props) => {
return (
@@ -27,16 +29,18 @@ const StudioRouteSwitch = ({
>
{appsLabel}
-
- {snippetsLabel}
-
+ {showSnippets && (
+
+ {snippetsLabel}
+
+ )}
)
}
diff --git a/web/app/components/billing/snippet-and-evaluation-plan-guard.tsx b/web/app/components/billing/snippet-and-evaluation-plan-guard.tsx
new file mode 100644
index 0000000000..39ed41a348
--- /dev/null
+++ b/web/app/components/billing/snippet-and-evaluation-plan-guard.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useEffect } from 'react'
+import Loading from '@/app/components/base/loading'
+import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
+import { useRouter } from '@/next/navigation'
+
+type SnippetAndEvaluationPlanGuardProps = {
+ children: ReactNode
+ fallbackHref: string
+}
+
+const SnippetAndEvaluationPlanGuard = ({
+ children,
+ fallbackHref,
+}: SnippetAndEvaluationPlanGuardProps) => {
+ const router = useRouter()
+ const { canAccess, isReady } = useSnippetAndEvaluationPlanAccess()
+
+ useEffect(() => {
+ if (isReady && !canAccess)
+ router.replace(fallbackHref)
+ }, [canAccess, fallbackHref, isReady, router])
+
+ if (!isReady) {
+ return (
+
+
+
+ )
+ }
+
+ if (!canAccess)
+ return null
+
+ return <>{children}>
+}
+
+export default SnippetAndEvaluationPlanGuard
diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts
index 6974f89c8b..024b32b346 100644
--- a/web/app/components/billing/utils/index.ts
+++ b/web/app/components/billing/utils/index.ts
@@ -1,6 +1,7 @@
import type { BasicPlan, BillingQuota, CurrentPlanInfoBackend } from '../type'
import dayjs from 'dayjs'
import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
+import { Plan } from '../type'
/**
* Parse vectorSpace string from ALL_PLANS config and convert to MB
@@ -116,3 +117,21 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
},
}
}
+
+export const canAccessSnippetsAndEvaluation = ({
+ enableBilling,
+ isFetchedPlan,
+ planType,
+}: {
+ enableBilling: boolean
+ isFetchedPlan: boolean
+ planType: Plan
+}) => {
+ if (!isFetchedPlan)
+ return !enableBilling
+
+ if (!enableBilling)
+ return true
+
+ return planType === Plan.professional || planType === Plan.team || planType === Plan.enterprise
+}
diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx
index 6d3bcb570e..a69dee643b 100644
--- a/web/app/components/snippets/index.tsx
+++ b/web/app/components/snippets/index.tsx
@@ -2,6 +2,7 @@
import { useMemo } from 'react'
import Loading from '@/app/components/base/loading'
+import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import {
@@ -68,9 +69,11 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => {
const SnippetPageWrapper = ({ snippetId }: SnippetPageProps) => {
return (
-
-
-
+
+
+
+
+
)
}
diff --git a/web/app/components/snippets/snippet-evaluation-page.tsx b/web/app/components/snippets/snippet-evaluation-page.tsx
index 5691be1977..f6aeeef335 100644
--- a/web/app/components/snippets/snippet-evaluation-page.tsx
+++ b/web/app/components/snippets/snippet-evaluation-page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
+import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import Evaluation from '@/app/components/evaluation'
import { getSnippetDetailMock } from '@/service/use-snippets.mock'
import SnippetLayout from './components/snippet-layout'
@@ -17,13 +18,15 @@ const SnippetEvaluationPage = ({ snippetId }: SnippetEvaluationPageProps) => {
return null
return (
-
-
-
+
+
+
+
+
)
}
diff --git a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx
index 6d27560802..67a271d49f 100644
--- a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx
@@ -2,7 +2,21 @@ import { act, renderHook } from '@testing-library/react'
import { useTabs, useToolTabs } from '../hooks'
import { TabsEnum, ToolTypeEnum } from '../types'
+const mockCanAccessSnippetsAndEvaluation = vi.fn(() => true)
+
+vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
+ useSnippetAndEvaluationPlanAccess: () => ({
+ canAccess: mockCanAccessSnippetsAndEvaluation(),
+ isReady: true,
+ }),
+}))
+
describe('block-selector hooks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCanAccessSnippetsAndEvaluation.mockReturnValue(true)
+ })
+
it('falls back to the first valid tab when the preferred start tab is disabled', () => {
const { result } = renderHook(() => useTabs({
noStart: false,
@@ -49,4 +63,12 @@ describe('block-selector hooks', () => {
expect(visible.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(true)
expect(hidden.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(false)
})
+
+ it('hides the snippets tab when snippet access is unavailable', () => {
+ mockCanAccessSnippetsAndEvaluation.mockReturnValue(false)
+
+ const { result } = renderHook(() => useTabs({}))
+
+ expect(result.current.tabs.some(tab => tab.key === TabsEnum.Snippets)).toBe(false)
+ })
})
diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts
index c645d0e30d..ac1026f96b 100644
--- a/web/app/components/workflow/block-selector/hooks.ts
+++ b/web/app/components/workflow/block-selector/hooks.ts
@@ -5,6 +5,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
+import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { BLOCKS } from './constants'
import {
TabsEnum,
@@ -42,6 +43,7 @@ export const useTabs = ({
forceEnableStartTab?: boolean
}) => {
const { t } = useTranslation()
+ const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const shouldShowStartTab = !noStart
const shouldDisableStartTab = disableStartTab || (!forceEnableStartTab && hasUserInputNode)
const startDisabledTip = disableStartTab
@@ -69,11 +71,11 @@ export const useTabs = ({
}, {
key: TabsEnum.Snippets,
name: t('tabs.snippets', { ns: 'workflow' }),
- show: true,
+ show: canAccessSnippetsAndEvaluation,
}]
return tabConfigs.filter(tab => tab.show)
- }, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab, startDisabledTip])
+ }, [canAccessSnippetsAndEvaluation, t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab, startDisabledTip])
const getValidTabKey = useCallback((targetKey?: TabsEnum) => {
if (!targetKey)
diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx
index 8286b7006e..bb72db67fa 100644
--- a/web/app/components/workflow/selection-contextmenu.tsx
+++ b/web/app/components/workflow/selection-contextmenu.tsx
@@ -18,6 +18,7 @@ import {
ContextMenuSeparator,
} from '@/app/components/base/ui/context-menu'
import { toast } from '@/app/components/base/ui/toast'
+import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { useRouter } from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { useCreateSnippetMutation } from '@/service/use-snippets'
@@ -296,6 +297,7 @@ const getSelectedSnippetGraph = (
const SelectionContextmenu = () => {
const { t } = useTranslation()
const { push } = useRouter()
+ const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const createSnippetMutation = useCreateSnippetMutation()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
@@ -342,7 +344,7 @@ const SelectionContextmenu = () => {
}, [selectedNodes])
const handleOpenCreateSnippetDialog = useCallback(() => {
- if (isAddToSnippetDisabled)
+ if (!canAccessSnippetsAndEvaluation || isAddToSnippetDisabled)
return
const nodes = store.getState().getNodes()
@@ -351,7 +353,7 @@ const SelectionContextmenu = () => {
setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes))
setIsCreateSnippetDialogOpen(true)
handleSelectionContextmenuCancel()
- }, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store])
+ }, [canAccessSnippetsAndEvaluation, handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store])
const handleCloseCreateSnippetDialog = useCallback(() => {
setIsCreateSnippetDialogOpen(false)
@@ -397,28 +399,37 @@ const SelectionContextmenu = () => {
}
}, [createSnippetMutation, handleCloseCreateSnippetDialog, push, t])
- const menuActions = useMemo(() => [
- {
- action: 'createSnippet',
- disabled: isAddToSnippetDisabled,
- translationKey: 'snippet.addToSnippet',
- },
- {
- action: 'copy',
- shortcutKeys: ['ctrl', 'c'],
- translationKey: 'common.copy',
- },
- {
- action: 'duplicate',
- shortcutKeys: ['ctrl', 'd'],
- translationKey: 'common.duplicate',
- },
- {
- action: 'delete',
- shortcutKeys: ['del'],
- translationKey: 'operation.delete',
- },
- ], [isAddToSnippetDisabled])
+ const menuActions = useMemo(() => {
+ const nextActions: ActionMenuItem[] = []
+
+ if (canAccessSnippetsAndEvaluation) {
+ nextActions.push({
+ action: 'createSnippet',
+ disabled: isAddToSnippetDisabled,
+ translationKey: 'snippet.addToSnippet',
+ })
+ }
+
+ nextActions.push(
+ {
+ action: 'copy',
+ shortcutKeys: ['ctrl', 'c'],
+ translationKey: 'common.copy',
+ },
+ {
+ action: 'duplicate',
+ shortcutKeys: ['ctrl', 'd'],
+ translationKey: 'common.duplicate',
+ },
+ {
+ action: 'delete',
+ shortcutKeys: ['del'],
+ translationKey: 'operation.delete',
+ },
+ )
+
+ return nextActions
+ }, [canAccessSnippetsAndEvaluation, isAddToSnippetDisabled])
const getActionLabel = useCallback((translationKey: string) => {
if (translationKey === 'operation.delete')
@@ -532,7 +543,7 @@ const SelectionContextmenu = () => {
data-testid={`selection-contextmenu-item-${item.action}`}
disabled={item.disabled}
className={cn(
- 'mx-0 h-8 justify-between gap-3 rounded-lg px-2 text-[14px] font-normal leading-5 text-text-secondary',
+ 'mx-0 h-8 justify-between gap-3 rounded-lg px-2 text-[14px] leading-5 font-normal text-text-secondary',
item.action === 'delete' && 'data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive',
)}
onClick={() => handleMenuAction(item.action)}
diff --git a/web/hooks/use-snippet-and-evaluation-plan-access.ts b/web/hooks/use-snippet-and-evaluation-plan-access.ts
new file mode 100644
index 0000000000..984a30fced
--- /dev/null
+++ b/web/hooks/use-snippet-and-evaluation-plan-access.ts
@@ -0,0 +1,19 @@
+'use client'
+
+import { canAccessSnippetsAndEvaluation } from '@/app/components/billing/utils'
+import { useProviderContextSelector } from '@/context/provider-context'
+
+export const useSnippetAndEvaluationPlanAccess = () => {
+ const planType = useProviderContextSelector(state => state.plan.type)
+ const enableBilling = useProviderContextSelector(state => state.enableBilling)
+ const isFetchedPlan = useProviderContextSelector(state => state.isFetchedPlan)
+
+ return {
+ canAccess: canAccessSnippetsAndEvaluation({
+ enableBilling,
+ isFetchedPlan,
+ planType,
+ }),
+ isReady: !enableBilling || isFetchedPlan,
+ }
+}