Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @vitest-environment jsdom
*/
import { act, type ComponentType, lazy, type ReactNode, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'

vi.mock('next/dynamic', () => ({
default: (load: () => Promise<ComponentType>) => lazy(async () => ({ default: await load() })),
}))
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() }))
vi.mock('@/lib/auth/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'viewer-1', role: 'user' } }, isPending: false }),
}))
vi.mock('@/lib/core/config/deployment-shape', () => ({
useDeploymentShape: () => ({ billingEnabled: false }),
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
useWorkspaceHostContext: () => ({
hostOrganizationId: 'organization-1',
workspace: { id: 'workspace-1' },
}),
}))
vi.mock('@/app/workspace/[workspaceId]/settings/components/general/general', () => ({
General: () => <div>General settings</div>,
}))
vi.mock(
'@/app/workspace/[workspaceId]/settings/components/team-management/team-management',
() => ({
TeamManagement: ({ organizationId }: { organizationId: string }) => (
<div>Members of {organizationId}</div>
),
})
)
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
SettingsSectionProvider: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
getSettingsSectionMeta: () => null,
}))

import { SettingsPage } from '@/app/workspace/[workspaceId]/settings/[section]/settings'

it('renders the inline member roster with billing disabled, while billing stays unavailable', async () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root = createRoot(container)
try {
await act(async () => {
root.render(
<Suspense>
<SettingsPage section='organization' />
</Suspense>
)
})
expect(container).toHaveTextContent('Members of organization-1')
expect(container).not.toHaveTextContent('General settings')

await act(async () => root.render(<SettingsPage section='billing' />))
expect(container).toHaveTextContent('General settings')
} finally {
act(() => root.unmount())
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
const normalizedSection: SettingsSection =
(section as string) === 'subscription' ? 'billing' : section
const effectiveSection =
!billingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization')
!billingEnabled && normalizedSection === 'billing'
? 'general'
: normalizedSection === 'admin' && !sessionLoading && !isAdminRole
? 'general'
Expand Down Expand Up @@ -192,7 +192,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
/>
)}
{effectiveSection === 'teammates' && <Teammates />}
{billingEnabled && effectiveSection === 'organization' && organizationId && (
{effectiveSection === 'organization' && organizationId && (
<TeamManagement
organizationId={organizationId}
billingHref={`/workspace/${hostContext.workspace.id}/settings/billing`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const {
deployment,
mockIsAdminOrOwner,
mockUseOrganization,
mockUseOrganizationBilling,
mockUseOrganizationRoster,
} = vi.hoisted(() => ({
deployment: { billingEnabled: true },
mockIsAdminOrOwner: vi.fn(),
mockUseOrganization: vi.fn(),
mockUseOrganizationBilling: vi.fn(),
Expand All @@ -22,6 +24,10 @@ vi.mock('@/lib/auth/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'viewer-1', email: 'viewer' } } }),
}))

vi.mock('@/lib/core/config/deployment-shape', () => ({
useDeploymentShape: () => deployment,
}))

vi.mock('@/lib/billing/client/utils', () => ({
getSubscriptionAccessState: () => ({
hasUsableTeamAccess: false,
Expand Down Expand Up @@ -121,6 +127,7 @@ let container: HTMLDivElement
let root: Root

beforeEach(() => {
deployment.billingEnabled = true
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
Expand All @@ -145,6 +152,28 @@ afterEach(() => {
})

describe('TeamManagement organization errors', () => {
it('renders members without fetching or displaying billing when billing is disabled', () => {
deployment.billingEnabled = false
mockIsAdminOrOwner.mockReturnValue(true)
mockUseOrganization.mockReturnValue({ data: { id: 'org-1' }, error: null, isLoading: false })
mockUseOrganizationBilling.mockReturnValue({
data: undefined,
error: new Error('Billing request failed'),
isLoading: false,
})

act(() =>
root.render(
<TeamManagement organizationId='org-1' billingHref='/workspace/ws-1/settings/billing' />
)
)

expect(mockUseOrganizationBilling).toHaveBeenCalledWith('org-1', { enabled: false })
expect(container).toHaveTextContent('organization-member-lists')
expect(container).not.toHaveTextContent('Billing request failed')
expect(container).not.toHaveTextContent('team-seats-overview')
})

it.each([
{ admin: true, canInvite: false, shown: true, disabled: true },
{ admin: true, canInvite: true, shown: true, disabled: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
Expand Down Expand Up @@ -53,6 +54,7 @@ export function TeamManagement({
canInviteMembers,
}: TeamManagementProps) {
const { data: session } = useSession()
const { billingEnabled } = useDeploymentShape()
const { isInvitationsDisabled } = usePermissionConfig()
const invitationsDisabled =
canInviteMembers === undefined ? isInvitationsDisabled : !canInviteMembers
Expand All @@ -71,7 +73,7 @@ export function TeamManagement({
* organization page derives its plan from organization billing, so avoid that unrelated read
* on the normal first paint.
*/
const shouldLoadRecoverySubscription = !isLoading && !orgError && !organization
const shouldLoadRecoverySubscription = billingEnabled && !isLoading && !orgError && !organization
const { data: userSubscriptionData, isPending: isRecoverySubscriptionPending } =
useSubscriptionData({
enabled: shouldLoadRecoverySubscription,
Expand All @@ -89,7 +91,7 @@ export function TeamManagement({
isFetchedAfterMount: isOrganizationBillingFetchedAfterMount,
isFetching: isOrganizationBillingFetching,
refetch: refetchOrganizationBilling,
} = useOrganizationBilling(organizationId, { enabled: adminOrOwner })
} = useOrganizationBilling(organizationId, { enabled: billingEnabled && adminOrOwner })

const {
data: roster,
Expand Down Expand Up @@ -148,7 +150,7 @@ export function TeamManagement({
* `client.subscription.list`, which does not reliably surface org-scoped
* subscriptions.
*/
const orgBilling = organizationBillingData?.data ?? null
const orgBilling = billingEnabled ? (organizationBillingData?.data ?? null) : null
const orgSubscription = orgBilling
? {
id: orgBilling.organizationId,
Expand Down Expand Up @@ -367,7 +369,8 @@ export function TeamManagement({
: []
}
>
{adminOrOwner &&
{billingEnabled &&
adminOrOwner &&
((organizationBillingError ||
(isOrganizationBillingFetching && isOrganizationBillingFetchedAfterMount)) &&
organizationBillingData === undefined ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ vi.mock('@/lib/billing/client', () => ({
getSubscriptionAccessState(...args),
}))
vi.mock('@/lib/core/config/deployment-shape', () => ({
useDeploymentShape: () => deployment,
useDeploymentShape: () => hostContext.deployment,
getDeploymentShape: () => deployment,
}))
vi.mock('@/lib/desktop', () => ({
Expand Down Expand Up @@ -199,6 +199,7 @@ describe('workspace SettingsSidebar organization rollout', () => {
renderSidebar()

expect(workspaceLink('connected-accounts')).toBeNull()
expect(workspaceLink('organization')).toBeNull()
})

it.each([false, undefined])(
Expand Down Expand Up @@ -264,12 +265,38 @@ describe('workspace SettingsSidebar organization rollout', () => {
renderSidebar()

expect(workspaceLink('billing')).toHaveTextContent('Subscription')
for (const section of ['organization', 'usage', 'sso']) {
expect(workspaceLink('organization')).toHaveTextContent('Members')
for (const section of ['usage', 'sso']) {
expect(workspaceLink(section)).toBeNull()
}
expectWorkspaceLinks()
})

it.each(['admin', 'member', 'external'] as const)(
'shows permitted inline settings for a self-hosted %s with Search and billing disabled',
(role) => {
hostContext = makeHostContext(role, false)
hostContext.deployment = { ...deployment, hosted: false, billingEnabled: false }
renderSidebar()

expect(container.querySelector('a[href^="/o/"]')).toBeNull()
expect(workspaceLink('billing')).toBeNull()
if (role === 'external') {
expect(workspaceLink('organization')).toBeNull()
} else {
expect(workspaceLink('organization')).toHaveTextContent('Members')
}
for (const section of ['connected-accounts', 'access-control', 'usage', 'sso', 'security']) {
if (role === 'admin') {
expect(workspaceLink(section)).not.toBeNull()
} else {
expect(workspaceLink(section)).toBeNull()
}
}
expectWorkspaceLinks()
}
)

it.each([false, true])(
'keeps external workspace admins out of organization settings when rollout is %s',
(enabled) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ export function SettingsSidebar({
: null
const subscriptionAccess = getSubscriptionAccessState(hostContext.ownerBilling)
const inboxEntitled = inboxConfig?.entitled ?? false
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
const isEnterprisePlan = subscriptionAccess.isEnterprise

Expand Down Expand Up @@ -164,6 +163,11 @@ export function SettingsSidebar({
) {
return false
}
if (item.id === 'organization') {
return Boolean(
hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationMember
)
}
if (item.requiresSelfHosted && hosted) {
return false
}
Expand Down Expand Up @@ -228,10 +232,6 @@ export function SettingsSidebar({

const orgAdminSatisfied = isOrgAdminOrOwner || item.allowNonOrgAdmin

if (item.requiresTeam && (!hasTeamPlan || !orgAdminSatisfied)) {
return false
}

if (
item.requiresEnterprise &&
(!hasEnterprisePlan || !orgAdminSatisfied) &&
Expand Down Expand Up @@ -264,7 +264,6 @@ export function SettingsSidebar({
deployment,
hosted,
billingEnabled,
hasTeamPlan,
hasEnterprisePlan,
isEnterprisePlan,
subscriptionAccess.hasUsableMaxAccess,
Expand Down
23 changes: 4 additions & 19 deletions apps/sim/components/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export interface UnifiedSettingsNavigationItem {
section: UnifiedNavigationSection
order: number
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
requiresEnterprise?: boolean
requiresMax?: boolean
requiresHosted?: boolean
Expand Down Expand Up @@ -473,16 +472,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
description: 'Members and workspace access in your organization.',
group: 'organization',
order: 0,
hideWhenBillingDisabled: true,
requiresHosted: true,
requiresTeam: true,
/**
* A plain member sees the roster read-only — `resolveOrganizationSectionAccess`
* grants them `'view'` on this one section, and `TeamManagement` renders
* without management controls. Every other organization section stays
* admin-only.
*/
allowNonOrgAdmin: true,
organizationSection: 'members',
},
},
Expand All @@ -495,14 +484,10 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
group: 'organization',
order: 1,
/**
* Deliberately no `hideWhenBillingDisabled`, unlike Members above.
*
* The sidebar applies that filter *before* it consults `selfHostedOverride`,
* so pairing the two hid this section from exactly the deployment the
* override exists to serve: self-hosted, billing off, `USAGE_MONITORING_ENABLED`
* on. Members can carry the flag because it has no override to reach. Here the
* two gates below already answer both cases — hosted needs the plan, and
* self-hosted needs the flag.
* Do not add `hideWhenBillingDisabled`: the sidebar applies it before
* `selfHostedOverride`, which would hide usage monitoring on self-hosted
* deployments with billing disabled. Hosted deployments require the plan;
* self-hosted deployments require the feature flag.
*/
requiresHosted: true,
requiresEnterprise: true,
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/lib/settings/application/workspace-section-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function authorize(section: Parameters<typeof authorizeWorkspaceSettingsSection>
describe('authorizeWorkspaceSettingsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.deploymentShape.billingEnabled = true
mocks.checkWorkspaceAccess.mockResolvedValue(PERSONAL_ACCESS)
mocks.isCustomBlocksEligibleForOrganization.mockResolvedValue(true)
mocks.isForkingAvailableForWorkspace.mockResolvedValue(true)
Expand Down Expand Up @@ -249,6 +250,33 @@ describe('authorizeWorkspaceSettingsSection', () => {
expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled()
})

it('allows the member roster with billing disabled while keeping billing unavailable', async () => {
mocks.deploymentShape.billingEnabled = false
mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS)

await expect(authorize('organization')).resolves.toEqual({ allowed: true })
expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith(
'organization-1',
'viewer-1',
'members'
)
await expect(authorize('billing')).resolves.toEqual({
allowed: false,
disposition: 'redirect-general',
})
})

it('requires current organization membership for the roster with billing disabled', async () => {
mocks.deploymentShape.billingEnabled = false
mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS)
mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false)

await expect(authorize('organization')).resolves.toEqual({
allowed: false,
disposition: 'redirect-general',
})
})

it.each([
{ groups: true, search: false, allowed: true },
{ groups: false, search: false, allowed: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,7 @@ async function canOpenOrganizationSection(
const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[input.section]
if (!organizationSection) return true
const deployment = getDeploymentShape()
if (
!deployment.billingEnabled &&
(input.section === 'billing' || input.section === 'organization')
) {
if (!deployment.billingEnabled && input.section === 'billing') {
return false
}
if (!workspace.organizationId) {
Expand Down
Loading