mirror of
https://github.com/langgenius/dify.git
synced 2026-07-24 04:49:53 +08:00
fix(web): restore billing access boundary (#39343)
This commit is contained in:
parent
861831f176
commit
fcc1d39f5e
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" />)
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 =
|
||||
|
||||
139
web/app/education-apply/__tests__/education-apply-page.spec.tsx
Normal file
139
web/app/education-apply/__tests__/education-apply-page.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user