fix(web): restore billing access boundary (#39343)

This commit is contained in:
yyh 2026-07-21 13:35:36 +08:00 committed by GitHub
parent 861831f176
commit fcc1d39f5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 334 additions and 284 deletions

View File

@ -317,9 +317,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"credential.use",
"credential.create",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app.acl.preview",
"app_library.access",
"app.create_and_management",
@ -349,9 +346,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"credential.use",
"credential.create",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app_library.access",
"app.create_and_management",
"app.tag.manage",
@ -377,9 +371,6 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"dataset.external.connect",
"snippets.create_and_modify",
"tool.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@ -387,9 +378,6 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
"plugin.install",
"credential.use",
"app_library.access",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
@ -805,7 +793,6 @@ class RBACService:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/role-permissions/catalog",
params={"billing_enabled": dify_config.BILLING_ENABLED},
tenant_id=tenant_id,
account_id=account_id,
)

View File

@ -46,7 +46,7 @@ class TestCatalog:
assert call.tenant_id == "tenant-1"
assert call.account_id == "acct-1"
assert call.json is None
assert call.params == {"billing_enabled": svc.dify_config.BILLING_ENABLED}
assert call.params is None
assert len(out.groups) == 1
assert out.groups[0].group_key == "workspace"
@ -624,12 +624,12 @@ class TestMyPermissions:
assert out.app.overrides == []
assert out.dataset.overrides == []
if role == "owner":
assert "billing.view" in out.workspace.permission_keys
assert "snippets.management" in out.workspace.permission_keys
assert "app.acl.preview" in out.workspace.permission_keys
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
assert not any(key.startswith("billing.") for key in out.workspace.permission_keys)
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys

View File

@ -41,10 +41,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState)
@ -143,7 +139,7 @@ const setupProviderContext = (
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
workspacePermissionKeys: [],
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...overrides,
@ -245,11 +241,11 @@ describe('Billing Page + Plan Integration', () => {
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when manager has subscription management permission', () => {
it('should show billing button to managers without billing permission keys', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.subscription.manage'],
workspacePermissionKeys: [],
})
render(<Billing />)
@ -258,11 +254,10 @@ describe('Billing Page + Plan Integration', () => {
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is granted without manager role', () => {
it('should hide billing button from non-manager members', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.subscription.manage'],
})
render(<Billing />)
@ -270,16 +265,16 @@ describe('Billing Page + Plan Integration', () => {
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when subscription management permission is missing', () => {
it('should show billing button when a manager has no billing permission keys', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage'],
workspacePermissionKeys: [],
})
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
})
it('should hide billing button when billing is disabled', () => {
@ -289,17 +284,6 @@ describe('Billing Page + Plan Integration', () => {
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when no billing permissions are granted', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
workspacePermissionKeys: [],
})
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
})
})
@ -366,6 +350,39 @@ describe('Plan Type Display Integration', () => {
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
})
it('should show education discount to managers without billing permission keys', () => {
setupProviderContext(
{ type: Plan.sandbox },
{
enableEducationPlan: true,
isEducationAccount: true,
},
)
setupConsoleState({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [] })
render(<PlanComp loc="test" />)
expect(screen.getByText(/useEducationDiscount/i)).toBeInTheDocument()
})
it('should hide education discount from non-manager members', () => {
setupProviderContext(
{ type: Plan.sandbox },
{
enableEducationPlan: true,
isEducationAccount: true,
},
)
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage'],
})
render(<PlanComp loc="test" />)
expect(screen.queryByText(/useEducationDiscount/i)).not.toBeInTheDocument()
})
})
// ═══════════════════════════════════════════════════════════════════════════

View File

@ -2,7 +2,7 @@
* Integration test: Cloud Plan Payment Flow
*
* Tests the payment flow for cloud plan items:
* CloudPlanItem Button click permission check fetch URL redirect
* CloudPlanItem Button click payment capability check fetch URL redirect
*
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
* and workspace manager permission enforcement.
@ -30,10 +30,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/billing', () => ({
@ -65,7 +61,6 @@ vi.mock('@/next/navigation', () => ({
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
...overrides,
}
}
@ -275,15 +270,11 @@ describe('Cloud Plan Payment Flow', () => {
})
})
// ─── 5. Permission Check ────────────────────────────────────────────────
describe('Permission check', () => {
it('should change plans when billing manage permission is granted without manager role', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage'],
})
// ─── 5. Payment capability ──────────────────────────────────────────────
describe('Payment capability', () => {
it('should change plans when payment is allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional, canPay: true })
const button = getPlanButton('billing.plansCommon.startBuilding')
await user.click(button)
@ -293,13 +284,9 @@ describe('Cloud Plan Payment Flow', () => {
})
})
it('should show error toast when billing manage permission is missing for plan changes', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.view', 'billing.subscription.manage'],
})
it('should block plan changes when payment is not allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional, canPay: false })
const button = getPlanButton('billing.plansCommon.startBuilding')
await user.click(button)
@ -310,13 +297,13 @@ describe('Cloud Plan Payment Flow', () => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
it('should open billing portal when subscription management permission is granted without manager role', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.subscription.manage'],
})
it('should open billing portal when payment is allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
renderCloudPlanItem({
currentPlan: Plan.professional,
plan: Plan.professional,
canPay: true,
})
const button = getPlanButton('billing.plansCommon.currentPlan')
await user.click(button)
@ -327,13 +314,13 @@ describe('Cloud Plan Payment Flow', () => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
it('should show error toast when subscription management permission is missing for current paid plan', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.view', 'billing.manage'],
})
it('should block billing portal access when payment is not allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
renderCloudPlanItem({
currentPlan: Plan.professional,
plan: Plan.professional,
canPay: false,
})
const button = getPlanButton('billing.plansCommon.currentPlan')
await user.click(button)

View File

@ -46,10 +46,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState)
@ -157,7 +153,6 @@ const setupContexts = (
}
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
userProfile: { email: 'student@university.edu' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...appOverrides,
@ -224,9 +219,13 @@ describe('Education Verification Flow', () => {
// ─── 2. Successful Verification Flow ────────────────────────────────────
describe('Successful verification flow', () => {
it('should navigate to education-apply with token on successful verification', async () => {
it('should let non-manager members start education verification', async () => {
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
setupContexts(
{},
{ enableEducationPlan: true, isEducationAccount: false },
{ isCurrentWorkspaceManager: false },
)
const user = userEvent.setup()
render(<PlanComp loc="test" />)

View File

@ -8,7 +8,7 @@
* Validates cross-component state propagation when the user switches between
* cloud / self-hosted categories and monthly / yearly plan ranges.
*/
import { cleanup, screen } from '@testing-library/react'
import { cleanup, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ALL_PLANS } from '@/app/components/billing/config'
@ -19,6 +19,7 @@ import { render } from '@/test/console/render'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
let mockConsoleState: Record<string, unknown> = {}
const mockFetchSubscriptionUrls = vi.hoisted(() => vi.fn())
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
@ -33,10 +34,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState)
@ -49,7 +46,7 @@ vi.mock('@/context/i18n', () => ({
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
}))
vi.mock('@/service/client', () => ({
@ -131,7 +128,6 @@ const setupContexts = (
}
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...appOverrides,
@ -145,6 +141,7 @@ describe('Pricing Modal Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com' })
setupContexts()
})
@ -266,6 +263,54 @@ describe('Pricing Modal Flow', () => {
// ─── 4. Cloud Plan Button States ─────────────────────────────────────────
describe('Cloud plan button states', () => {
it('should allow managers without billing permission keys to change plans', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
})
})
it('should default education account managers to yearly checkout', async () => {
setupContexts()
mockProviderCtx = {
...mockProviderCtx,
enableEducationPlan: true,
isEducationAccount: true,
}
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
expect(screen.getByRole('switch')).toBeChecked()
await user.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
})
})
it('should block non-manager members even when billing permission keys are present', async () => {
setupContexts(
{},
{
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage', 'billing.subscription.manage'],
},
)
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
})
it('should show "Current Plan" for the current plan (sandbox)', () => {
setupContexts({ type: Plan.sandbox })
render(<Pricing onCancel={onCancel} />)

View File

@ -1,14 +1,12 @@
import { toast, ToastHost } from '@langgenius/dify-ui/toast'
/**
* Integration test: Self-Hosted Plan Flow
*
* Tests the self-hosted plan items:
* SelfHostedPlanItem Button click permission check redirect to external URL
* SelfHostedPlanItem Button click redirect to external URL
*
* Covers community/premium/enterprise plan rendering, external URL navigation,
* and workspace manager permission enforcement.
* Covers community/premium/enterprise plan rendering and external URL navigation.
*/
import { cleanup, screen, waitFor } from '@testing-library/react'
import { cleanup, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import {
@ -20,20 +18,9 @@ import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-host
import { SelfHostedPlan } from '@/app/components/billing/type'
import { render } from '@/test/console/render'
let mockConsoleState: Record<string, unknown> = {}
const originalLocation = window.location
let assignedHref = ''
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
useTheme: () => ({ theme: 'light' }),
@ -52,29 +39,14 @@ vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', ()
),
}))
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.manage'],
...overrides,
}
}
const renderSelfHostedPlanItem = (plan: SelfHostedPlan) => {
return render(
<>
<ToastHost timeout={0} />
<SelfHostedPlanItem plan={plan} />
</>,
)
return render(<SelfHostedPlanItem plan={plan} />)
}
describe('Self-Hosted Plan Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
toast.dismiss()
setupConsoleState()
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
assignedHref = ''
@ -187,64 +159,4 @@ describe('Self-Hosted Plan Flow', () => {
expect(assignedHref).toBe(contactSalesUrl)
})
})
// ─── 3. Permission Check ────────────────────────────────────────────────
describe('Permission check', () => {
it('should redirect when billing manage permission is granted without manager role', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage'],
})
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
expect(assignedHref).toBe(getStartedWithCommunityUrl)
})
it('should show error toast when billing manage permission is missing for community button', async () => {
setupConsoleState({ workspacePermissionKeys: [] })
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
// Should NOT redirect
expect(assignedHref).toBe('')
})
it('should show error toast when billing manage permission is missing for premium button', async () => {
setupConsoleState({ workspacePermissionKeys: [] })
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.premium)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
it('should show error toast when billing manage permission is missing for enterprise button', async () => {
setupConsoleState({ workspacePermissionKeys: [] })
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
})
})

View File

@ -6,7 +6,6 @@ let currentBillingUrl: string | null = 'https://billing'
let fetching = false
let isManager = true
let enableBilling = true
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
let billingUrlEnabled = false
const refetchMock = vi.fn()
@ -39,14 +38,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
isCurrentWorkspaceManager: isManager,
workspacePermissionKeys,
}))
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
isCurrentWorkspaceManager: isManager,
workspacePermissionKeys,
}))
})
@ -68,11 +59,10 @@ describe('Billing', () => {
isManager = true
enableBilling = true
billingUrlEnabled = false
workspacePermissionKeys = ['billing.subscription.manage']
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('hides the billing action when subscription management permission is granted without manager role', () => {
it('hides the billing action from non-manager members', () => {
isManager = false
render(<Billing />)
@ -83,16 +73,14 @@ describe('Billing', () => {
expect(billingUrlEnabled).toBe(false)
})
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
workspacePermissionKeys = []
it('shows the billing action to managers without billing permission keys', () => {
render(<Billing />)
expect(
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
vi.clearAllMocks()
workspacePermissionKeys = ['billing.subscription.manage']
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
expect(billingUrlEnabled).toBe(true)
})
it('hides the billing action when billing is disabled', () => {
enableBilling = false
render(<Billing />)
expect(

View File

@ -3,27 +3,21 @@ import type { FC } from 'react'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useBillingUrl } from '@/service/use-billing'
import { BillingPermission, hasPermission } from '@/utils/permission'
import PlanComp from '../plan'
const Billing: FC = () => {
const { t } = useTranslation()
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { enableBilling } = useProviderContext()
const canManageBillingSubscription =
isCurrentWorkspaceManager &&
hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
const {
data: billingUrl,
isFetching,
refetch,
} = useBillingUrl(enableBilling && canManageBillingSubscription)
} = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
const openAsyncWindow = useAsyncWindowOpen()
const handleOpenBilling = async () => {
@ -46,7 +40,7 @@ const Billing: FC = () => {
return (
<div>
<PlanComp loc="billing-page" />
{enableBilling && canManageBillingSubscription && (
{enableBilling && isCurrentWorkspaceManager && (
<button
type="button"
className="mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3"

View File

@ -3,21 +3,19 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { fetchSubscriptionUrls } from '@/service/billing'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { Plan } from '../type'
export const useEducationDiscount = () => {
const { t } = useTranslation()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const handleEducationDiscount = useCallback(async () => {
if (isEducationDiscountLoading) return
if (!canManageBilling) {
if (!isCurrentWorkspaceManager) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
@ -29,7 +27,7 @@ export const useEducationDiscount = () => {
} finally {
setIsEducationDiscountLoading(false)
}
}, [canManageBilling, isEducationDiscountLoading, t])
}, [isCurrentWorkspaceManager, isEducationDiscountLoading, t])
return {
handleEducationDiscount,

View File

@ -14,11 +14,10 @@ import VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { IS_CLOUD_EDITION } from '@/config'
import { userProfileEmailAtom } from '@/context/account-state'
import { useModalContextSelector } from '@/context/modal-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { usePathname, useRouter } from '@/next/navigation'
import { useEducationVerify } from '@/service/use-education'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import Loading from '../../base/icons/src/public/thought/Loading'
import { NUM_INFINITE } from '../config'
@ -38,7 +37,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
const router = useRouter()
const path = usePathname()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } =
useProviderContext()
const isAboutToExpire = allowRefreshEducationVerify
@ -59,7 +58,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
const [showModal, setShowModal] = React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const setEducationVerifying = useSetEducationVerifying()
@ -112,7 +110,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
enableEducationPlan &&
isEducationAccount &&
type === Plan.sandbox &&
canManageBilling && (
isCurrentWorkspaceManager && (
<Button
variant="ghost"
onClick={handleEducationDiscount}

View File

@ -50,10 +50,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
@ -79,7 +75,6 @@ describe('Pricing dialog lifecycle', () => {
latestOnOpenChange = undefined
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.manage'],
}
;(useProviderContext as Mock).mockReturnValue({
plan: {

View File

@ -14,9 +14,8 @@ import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useState } from 'react'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { NoiseBottom, NoiseTop } from './assets'
import Footer from './footer'
import Header from './header'
@ -31,14 +30,13 @@ type PricingProps = {
const Pricing: FC<PricingProps> = ({ onCancel }) => {
const { plan, enableEducationPlan, isEducationAccount } = useProviderContext()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const shouldDefaultToYearly = canManageBilling && enableEducationPlan && isEducationAccount
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const shouldDefaultToYearly =
isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount
const [selectedPlanRange, setSelectedPlanRange] = React.useState<PlanRange>()
const planRange =
selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly)
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
const canPay = canManageBilling
const pricingPageLanguage = useGetPricingPageLanguage()
const pricingPageURL = pricingPageLanguage
@ -71,7 +69,7 @@ const Pricing: FC<PricingProps> = ({ onCancel }) => {
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
canPay={isCurrentWorkspaceManager}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute inset-x-0 -bottom-12 -z-10">

View File

@ -10,16 +10,13 @@ import {
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { ALL_PLANS } from '../../../config'
import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
@ -52,12 +49,6 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
const isCurrent = plan === currentPlan
const isCurrentPaidPlan = isCurrent && !isFreePlan
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const canManageBillingSubscription = hasPermission(
workspacePermissionKeys,
BillingPermission.SubscriptionManage,
)
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
@ -90,7 +81,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
setLoading(true)
try {
if (isCurrentPaidPlan) {
if (!canManageBillingSubscription) {
if (!canPay) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
@ -112,7 +103,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
if (isFreePlan) return
if (!canManageBilling) {
if (!canPay) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}

View File

@ -1,14 +1,10 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Azure, GoogleCloud } from '@/app/components/base/icons/src/public/billing'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { SelfHostedPlan } from '../../../type'
import { Community, Enterprise, EnterpriseNoise, Premium, PremiumNoise } from '../../assets'
@ -51,14 +47,8 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ plan }) => {
const isFreePlan = plan === SelfHostedPlan.community
const isPremiumPlan = plan === SelfHostedPlan.premium
const isEnterprisePlan = plan === SelfHostedPlan.enterprise
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const handleGetPayUrl = useCallback(() => {
if (!canManageBilling) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
if (isFreePlan) {
window.location.href = getStartedWithCommunityUrl
return
@ -69,7 +59,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ plan }) => {
}
if (isEnterprisePlan) window.location.href = contactSalesUrl
}, [canManageBilling, isFreePlan, isPremiumPlan, isEnterprisePlan, t])
}, [isFreePlan, isPremiumPlan, isEnterprisePlan])
return (
<div className="relative flex flex-1 flex-col overflow-hidden">

View File

@ -199,9 +199,6 @@ const baseConsoleState: ConsoleStateFixture = {
'data_source.manage',
'api_extension.manage',
'customization.manage',
'billing.view',
'billing.manage',
'billing.subscription.manage',
],
}
@ -381,7 +378,7 @@ describe('AccountSetting', () => {
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should not hide workspace menu items solely for dataset operators', () => {
it('should hide billing from dataset operators', () => {
// Arrange
const datasetOperatorContext = {
...baseConsoleState,
@ -400,7 +397,9 @@ describe('AccountSetting', () => {
expect(
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.settings.billing' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
expect(
@ -512,23 +511,26 @@ describe('AccountSetting', () => {
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
})
it('should hide billing entry when billing view permission is missing', () => {
it('should show billing to regular members without billing permission keys', () => {
// Arrange
const contextWithoutBillingViewPermission = {
const regularMemberContext = {
...baseConsoleState,
workspacePermissionKeys: baseConsoleState.workspacePermissionKeys!.filter(
(key) => key !== 'billing.view',
),
currentWorkspace: {
...baseConsoleState.currentWorkspace,
role: 'normal' as const,
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceDatasetOperator: false,
workspacePermissionKeys: [],
}
mockConsoleState.current = contextWithoutBillingViewPermission
mockConsoleState.current = regularMemberContext
// Act
renderAccountSetting()
// Assert
expect(
screen.queryByRole('button', { name: 'common.settings.billing' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
})
it('should hide workflow log archives outside cloud edition', () => {
@ -582,20 +584,36 @@ describe('AccountSetting', () => {
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
})
it('should not render billing page when active billing tab lacks billing view permission', () => {
it('should render a direct billing entry for regular members without billing permission keys', () => {
// Arrange
const contextWithoutBillingViewPermission = {
const regularMemberContext = {
...baseConsoleState,
workspacePermissionKeys: baseConsoleState.workspacePermissionKeys!.filter(
(key) => key !== 'billing.view',
),
currentWorkspace: {
...baseConsoleState.currentWorkspace,
role: 'normal' as const,
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceDatasetOperator: false,
workspacePermissionKeys: [],
}
mockConsoleState.current = contextWithoutBillingViewPermission
mockConsoleState.current = regularMemberContext
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.BILLING })
// Assert
expect(screen.getByTestId('billing-page')).toBeInTheDocument()
})
it('should not render a direct billing entry for dataset operators', () => {
mockConsoleState.current = {
...baseConsoleState,
isCurrentWorkspaceDatasetOperator: true,
}
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.BILLING })
expect(screen.queryByTestId('billing-page')).not.toBeInTheDocument()
})
})

View File

@ -14,10 +14,13 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { IS_CLOUD_EDITION } from '@/config'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import {
isCurrentWorkspaceDatasetOperatorAtom,
isCurrentWorkspaceManagerAtom,
} from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { hasPermission } from '@/utils/permission'
import AccessRulesPage from './access-rules-page'
import { ApiBasedExtensionPage } from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
@ -58,11 +61,11 @@ export default function AccountSetting({
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles =
isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const canViewBilling =
enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
const canViewBilling = enableBilling && !isCurrentWorkspaceDatasetOperator
const canViewWorkflowLogArchives = IS_CLOUD_EDITION && isCurrentWorkspaceManager
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
const normalizedActiveTab =

View File

@ -0,0 +1,139 @@
import { QueryClient } from '@tanstack/react-query'
import { cleanup, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { render } from '@/test/console/render'
let mockProviderContext: Record<string, unknown> = {}
let mockConsoleState: Record<string, unknown> = {}
const mockFetchSubscriptionUrls = vi.hoisted(() => vi.fn())
const mockEducationAdd = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContext,
}))
vi.mock('@/context/account-state', async () => {
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
return createAccountStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams('token=education-token'),
}))
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
}))
vi.mock('@/service/use-education', () => ({
useEducationAdd: () => ({ isPending: false, mutateAsync: mockEducationAdd }),
useInvalidateEducationStatus: () => vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useLogout: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
billing: {
invoices: {
get: vi.fn().mockResolvedValue({ url: 'https://billing.example.com' }),
},
},
},
consoleQuery: {
workspaces: {
get: {
queryOptions: () => ({
queryKey: ['workspaces'],
queryFn: async () => ({ workspaces: [] }),
}),
},
switch: {
post: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
},
},
}))
const setupContext = (isCurrentWorkspaceManager: boolean) => {
mockProviderContext = {
plan: { type: Plan.sandbox },
isEducationAccount: true,
onPlanInfoChanged: vi.fn(),
}
mockConsoleState = {
currentWorkspace: { id: 'workspace-1', name: 'Workspace' },
isCurrentWorkspaceManager,
userProfile: {
name: 'Student',
email: 'student@university.edu',
avatar_url: '',
},
}
}
const renderPage = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(<EducationApplyPage />, {
wrapper: createQueryClientWrapper(queryClient),
})
}
describe('EducationApplyPage billing boundary', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
mockFetchSubscriptionUrls.mockResolvedValue({ url: window.location.href })
})
it('lets workspace managers apply the education coupon at checkout', async () => {
setupContext(true)
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
})
})
it('shows non-manager members that they cannot apply the coupon to payment', () => {
setupContext(false)
renderPage()
expect(
screen.getByText('education.applied.noPaymentPermission.description'),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'education.useEducationDiscount' }),
).not.toBeInTheDocument()
})
})

View File

@ -15,14 +15,12 @@ import { useEducationDiscount } from '@/app/components/billing/hooks/use-educati
import { Plan } from '@/app/components/billing/type'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import { useDocLink } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { currentWorkspaceAtom } from '@/context/workspace-state'
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useRouter, useSearchParams } from '@/next/navigation'
import { consoleClient, consoleQuery } from '@/service/client'
import { useEducationAdd, useInvalidateEducationStatus } from '@/service/use-education'
import { BillingPermission, hasPermission } from '@/utils/permission'
import DifyLogo from '../components/base/logo/dify-logo'
import AppliedEducationContent from './applied-education-content'
import RoleSelector from './role-selector'
@ -46,7 +44,7 @@ const EducationApplyAgeContent = () => {
const { isPending, mutateAsync: educationAdd } = useEducationAdd({ onSuccess: noop })
const { onPlanInfoChanged, isEducationAccount, plan } = useProviderContext()
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const updateEducationStatus = useInvalidateEducationStatus()
const docLink = useDocLink()
const { handleEducationDiscount } = useEducationDiscount()
@ -58,9 +56,8 @@ const EducationApplyAgeContent = () => {
const searchParams = useSearchParams()
const token = searchParams.get('token')
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const appliedEducationCase = (() => {
if (!canManageBilling) return AppliedEducationCase.noPaymentPermission
if (!isCurrentWorkspaceManager) return AppliedEducationCase.noPaymentPermission
if (plan.type === Plan.sandbox) return AppliedEducationCase.eligible

View File

@ -29,12 +29,6 @@ export const DatasetACLPermission = {
AccessConfig: 'dataset.acl.access_config',
} as const
export const BillingPermission = {
View: 'billing.view',
Manage: 'billing.manage',
SubscriptionManage: 'billing.subscription.manage',
} as const
export type ResourceMaintainerPermissionOptions = {
currentUserId?: string | null
resourceMaintainer?: string | null