diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.test.tsx new file mode 100644 index 00000000000..e02067414a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.test.tsx @@ -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) => 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: () =>
General settings
, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/team-management/team-management', + () => ({ + TeamManagement: ({ organizationId }: { organizationId: string }) => ( +
Members of {organizationId}
+ ), + }) +) +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( + + + + ) + }) + expect(container).toHaveTextContent('Members of organization-1') + expect(container).not.toHaveTextContent('General settings') + + await act(async () => root.render()) + expect(container).toHaveTextContent('General settings') + } finally { + act(() => root.unmount()) + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index ae626e7a9d9..6fc7c8495aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -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' @@ -192,7 +192,7 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'teammates' && } - {billingEnabled && effectiveSection === 'organization' && organizationId && ( + {effectiveSection === 'organization' && organizationId && ( ({ + deployment: { billingEnabled: true }, mockIsAdminOrOwner: vi.fn(), mockUseOrganization: vi.fn(), mockUseOrganizationBilling: vi.fn(), @@ -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, @@ -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) @@ -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( + + ) + ) + + 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 }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 6bab7fe2a95..2ff61ebb00e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -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' @@ -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 @@ -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, @@ -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, @@ -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, @@ -367,7 +369,8 @@ export function TeamManagement({ : [] } > - {adminOrOwner && + {billingEnabled && + adminOrOwner && ((organizationBillingError || (isOrganizationBillingFetching && isOrganizationBillingFetchedAfterMount)) && organizationBillingData === undefined ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx index 118bae39c16..4e5bb7e5e31 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx @@ -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', () => ({ @@ -199,6 +199,7 @@ describe('workspace SettingsSidebar organization rollout', () => { renderSidebar() expect(workspaceLink('connected-accounts')).toBeNull() + expect(workspaceLink('organization')).toBeNull() }) it.each([false, undefined])( @@ -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) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 642a8ea2679..4dc93d6ca6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -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 @@ -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 } @@ -228,10 +232,6 @@ export function SettingsSidebar({ const orgAdminSatisfied = isOrgAdminOrOwner || item.allowNonOrgAdmin - if (item.requiresTeam && (!hasTeamPlan || !orgAdminSatisfied)) { - return false - } - if ( item.requiresEnterprise && (!hasEnterprisePlan || !orgAdminSatisfied) && @@ -264,7 +264,6 @@ export function SettingsSidebar({ deployment, hosted, billingEnabled, - hasTeamPlan, hasEnterprisePlan, isEnterprisePlan, subscriptionAccess.hasUsableMaxAccess, diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 7721f1c7841..d0ef0738fbc 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -138,7 +138,6 @@ export interface UnifiedSettingsNavigationItem { section: UnifiedNavigationSection order: number hideWhenBillingDisabled?: boolean - requiresTeam?: boolean requiresEnterprise?: boolean requiresMax?: boolean requiresHosted?: boolean @@ -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', }, }, @@ -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, diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index ca9793b6b91..a8ca619c801 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -118,6 +118,7 @@ function authorize(section: Parameters describe('authorizeWorkspaceSettingsSection', () => { beforeEach(() => { vi.clearAllMocks() + mocks.deploymentShape.billingEnabled = true mocks.checkWorkspaceAccess.mockResolvedValue(PERSONAL_ACCESS) mocks.isCustomBlocksEligibleForOrganization.mockResolvedValue(true) mocks.isForkingAvailableForWorkspace.mockResolvedValue(true) @@ -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 }, diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 90e00290cf5..daa8201117b 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -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) {