From 470c8d4f978bdba542718dc791c8268c4d48de38 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 15 Sep 2026 14:09:58 -0700 Subject: [PATCH 1/2] fix(settings): decouple organization access from Sim Search --- .../app/o/[organizationId]/home/page.test.tsx | 6 +- .../app/o/[organizationId]/layout.test.tsx | 46 +++++++--- apps/sim/app/o/[organizationId]/layout.tsx | 12 +-- apps/sim/app/o/[organizationId]/page.tsx | 4 +- .../app/o/[organizationId]/prefetch.test.ts | 14 ++- apps/sim/app/o/[organizationId]/prefetch.ts | 26 +++--- .../settings/[section]/page.test.tsx | 24 ++--- .../[workspaceId]/settings/[section]/page.tsx | 2 +- .../settings-sidebar.test.tsx | 91 +++++++------------ .../settings-sidebar/settings-sidebar.tsx | 21 +---- .../workspace-header.test.tsx | 8 +- .../lib/workspaces/organization-navigation.ts | 4 +- 12 files changed, 125 insertions(+), 133 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx index c5b4d414c24..8c2c68229e2 100644 --- a/apps/sim/app/o/[organizationId]/home/page.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx @@ -101,11 +101,9 @@ describe('organization Search page gates', () => { expect(mocks.context).not.toHaveBeenCalled() }) - it('returns the organization entry to workspace settings when Search is disabled', async () => { + it('lands the organization entry on Members settings when Search is disabled', async () => { mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } }) - await expect(OrganizationPage({ params })).rejects.toThrow( - 'redirect:/workspace?redirect=settings' - ) + await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/settings/members') expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer') }) diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx index 0ad7389f7ac..9bd9394de1b 100644 --- a/apps/sim/app/o/[organizationId]/layout.test.tsx +++ b/apps/sim/app/o/[organizationId]/layout.test.tsx @@ -123,7 +123,8 @@ describe('OrganizationLayout', () => { {}, 'org-1', { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, - 'active-org' + 'active-org', + true ) expect(html).toContain('Organization child') expect(mockUseMothershipChatEvents).toHaveBeenCalledWith( @@ -158,14 +159,15 @@ describe('OrganizationLayout', () => { {}, 'org-1', { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, - null + null, + true ) expect(html).toContain('Impersonating QA Member (member@example.com)') expect(html).toContain('Stop impersonating') expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child')) }) - it('does not use the impersonating admin to enter an organization outside the rollout', async () => { + it('uses the impersonated member to open settings when Search is disabled', async () => { mockGetSession.mockResolvedValue({ user: { id: 'customer-member' }, session: { id: 'session-1', impersonatedBy: 'platform-admin' }, @@ -175,18 +177,24 @@ describe('OrganizationLayout', () => { searchAccess: { memberScoped: false, sourceMirrored: false }, }) - await expect( - OrganizationLayout({ + const html = renderToStaticMarkup( + await OrganizationLayout({ children:
Organization child
, params: Promise.resolve({ organizationId: 'customer-org' }), }) - ).rejects.toThrow('redirect:/workspace?redirect=settings') + ) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith( 'customer-org', 'customer-member' ) - expect(mockWorkspaceChrome).not.toHaveBeenCalled() - expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() + expect(html).toContain('Organization child') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'customer-org', + { kind: 'session', userId: 'customer-member', sessionId: 'session-1' }, + null, + false + ) }) it('renders an explicit denial for a non-member without the surface', async () => { @@ -205,7 +213,7 @@ describe('OrganizationLayout', () => { }) it.each(['owner', 'admin', 'member'])( - 'returns %s viewers outside the rollout to workspace settings before rendering org chrome', + 'renders organization settings for a %s when Search is disabled', async (role) => { mockGetOrganizationSurfaceContext.mockResolvedValue({ ...SURFACE_CONTEXT, @@ -213,14 +221,24 @@ describe('OrganizationLayout', () => { searchAccess: { memberScoped: false, sourceMirrored: true }, }) - await expect( - OrganizationLayout({ + const html = renderToStaticMarkup( + await OrganizationLayout({ children:
Organization settings
, params: Promise.resolve({ organizationId: 'org-1' }), }) - ).rejects.toThrow('redirect:/workspace?redirect=settings') - expect(mockWorkspaceChrome).not.toHaveBeenCalled() - expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() + ) + expect(html).toContain('Organization settings') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + 'active-org', + false + ) + expect(mockUseMothershipChatEvents).toHaveBeenCalledWith( + undefined, + SURFACE_CONTEXT.deployment.chatEnabled + ) } ) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index fb4b66e85bd..520a50c4796 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -3,7 +3,7 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' -import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { organizationRoutes } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -18,9 +18,8 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/ /** * The organization surface: the viewer's own view of one organization, outside - * any workspace. Requires membership and the organization's Search rollout. - * Non-members get an explicit denial; members outside the rollout retain - * workspace settings, including when following a saved organization link. + * any workspace. Requires membership; Search availability is checked by the + * individual pages and controls that use it. */ export default async function OrganizationLayout({ children, @@ -48,13 +47,12 @@ export default async function OrganizationLayout({ if (!context) { return } - if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) - await prefetchOrganizationSidebar( queryClient, organizationId, { kind: 'session', userId: session.user.id, sessionId: session.session.id }, - getActiveOrganizationId(session) + getActiveOrganizationId(session), + context.searchAccess.memberScoped ) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx index fd6726efd4f..cc544bd0371 100644 --- a/apps/sim/app/o/[organizationId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/page.tsx @@ -1,6 +1,6 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { organizationRoutes } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -17,5 +17,5 @@ export default async function OrganizationPage({ } const context = await getOrganizationSurfaceContext(organizationId, session.user.id) if (!context) notFound() - redirect(context.searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH) + redirect(context.searchAccess.memberScoped ? routes.home : routes.settingsSection('members')) } diff --git a/apps/sim/app/o/[organizationId]/prefetch.test.ts b/apps/sim/app/o/[organizationId]/prefetch.test.ts index 679b8c88e24..e53b4443d68 100644 --- a/apps/sim/app/o/[organizationId]/prefetch.test.ts +++ b/apps/sim/app/o/[organizationId]/prefetch.test.ts @@ -61,8 +61,8 @@ function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } -function prefetch(client: QueryClient) { - return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org') +function prefetch(client: QueryClient, searchAvailable = true) { + return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org', searchAvailable) } describe('organization sidebar hydration', () => { @@ -132,6 +132,16 @@ describe('organization sidebar hydration', () => { expect(dehydrate(client).queries).toHaveLength(3) }) + it('hydrates workspace and profile data without loading Search chats when Search is disabled', async () => { + const client = makeClient() + await prefetch(client, false) + + expect(mockListOrganizationChats).not.toHaveBeenCalled() + expect(client.getQueryState(CHAT_KEY)).toBeUndefined() + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(client.getQueryData(userProfileKeys.profile())).toMatchObject({ name: 'Ada' }) + }) + it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => { mockListOrganizationChats.mockResolvedValue([]) mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] }) diff --git a/apps/sim/app/o/[organizationId]/prefetch.ts b/apps/sim/app/o/[organizationId]/prefetch.ts index 0ed2be1f62f..572160c941c 100644 --- a/apps/sim/app/o/[organizationId]/prefetch.ts +++ b/apps/sim/app/o/[organizationId]/prefetch.ts @@ -18,20 +18,22 @@ export async function prefetchOrganizationSidebar( queryClient: QueryClient, organizationId: string, principal: SessionPrincipal, - activeOrganizationId: string | null + activeOrganizationId: string | null, + searchAvailable: boolean ): Promise { await Promise.all([ - queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), - queryFn: async () => { - const chats = await listOrganizationChats.execute({ - principal, - input: { organizationId, scope: 'active' }, - }) - return chats.map(mapChat) - }, - staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, - }), + searchAvailable && + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), + queryFn: async () => { + const chats = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope: 'active' }, + }) + return chats.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), seedWorkspaceList(queryClient, principal.userId, activeOrganizationId), prefetchUserProfile(queryClient, principal.userId), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index bfd7f13b9f7..5a00bd8b823 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -112,17 +112,17 @@ describe('WorkspaceSettingsSectionPage', () => { }) it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))( - 'keeps %s in the workspace outside the organization rollout', - async (section) => { + 'routes %s to organization %s when Search is disabled', + async (section, organizationSection) => { mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features: { organizationSearch: false, knowledgeMemberAccess: true }, }) - const element = await WorkspaceSettingsSectionPage(pageProps(section)) - - expect(element).toBeTruthy() - expect(mockRedirect).not.toHaveBeenCalled() + await expect(WorkspaceSettingsSectionPage(pageProps(section))).rejects.toThrow( + `NEXT_REDIRECT:/o/org-target/settings/${organizationSection}` + ) + expect(mockSectionPrefetch).not.toHaveBeenCalled() expect(mockAuthorizeSection).toHaveBeenCalledWith({ workspaceId: 'workspace-b', userId: 'viewer-a', @@ -132,19 +132,19 @@ describe('WorkspaceSettingsSectionPage', () => { ) it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])( - 'keeps settings in the workspace when older host context omits the org rollout', + 'routes settings to the organization when older host context omits Search availability', async (features) => { mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features }) - await WorkspaceSettingsSectionPage(pageProps('billing')) - - expect(mockRedirect).not.toHaveBeenCalled() - expect(mockSectionPrefetch).toHaveBeenCalledTimes(1) + await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow( + 'NEXT_REDIRECT:/o/org-target/settings/billing' + ) + expect(mockSectionPrefetch).not.toHaveBeenCalled() } ) it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))( - 'routes %s to organization %s only within the organization rollout', + 'routes %s to organization %s when Search is enabled', async (section, organizationSection) => { mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 04ba51f88e6..34ce91bc3e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -60,7 +60,7 @@ export default async function WorkspaceSettingsSectionPage({ const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[parsed] if (organizationSection) { const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) - if (hostContext?.hostOrganizationId && hostContext.features?.organizationSearch) { + if (hostContext?.hostOrganizationId) { const query = new URLSearchParams() for (const [key, value] of Object.entries((await searchParams) ?? {})) { for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) { 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..dec13a96617 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', () => ({ @@ -185,13 +185,24 @@ function expectWorkspaceLinks() { expect(workspaceLink('secrets')).toHaveTextContent('Secrets') } -describe('workspace SettingsSidebar organization rollout', () => { +function expectOrganizationLink() { + const links = container.querySelectorAll('a[href^="/o/"]') + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members') + expect(links[0]).toHaveTextContent('Organization') + for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { + expect(workspaceLink(section)).toBeNull() + } + expectWorkspaceLinks() +} + +describe('workspace SettingsSidebar organization navigation', () => { it('hides Connected accounts when credential groups are disabled', () => { hostContext.features = { ...hostContext.features!, credentialGroups: false } renderSidebar() expect(workspaceLink('connected-accounts')).toBeNull() - expectWorkspaceLinks() + expectOrganizationLink() }) it('does not offer organization accounts in a personal workspace', () => { @@ -199,79 +210,47 @@ describe('workspace SettingsSidebar organization rollout', () => { renderSidebar() expect(workspaceLink('connected-accounts')).toBeNull() + expect(container.querySelector('a[href^="/o/"]')).toBeNull() }) - it.each([false, undefined])( - 'keeps organization settings in the workspace for an admin when rollout is %s', - (enabled) => { - hostContext = makeHostContext('admin', enabled) - renderSidebar() + it.each([ + { role: 'admin', search: true }, + { role: 'admin', search: false }, + { role: 'admin', search: undefined }, + { role: 'member', search: true }, + { role: 'member', search: false }, + { role: 'member', search: undefined }, + ] as const)('links a $role to organization settings with Search $search', ({ role, search }) => { + hostContext = makeHostContext(role, search) + renderSidebar() - expect(workspaceLink('organization')).toHaveTextContent('Members') - expect(workspaceLink('billing')).toHaveTextContent('Subscription') - expect(workspaceLink('usage')).toHaveTextContent('Usage tracking') - expect(workspaceLink('sso')).toHaveTextContent('Single sign-on') - expect(workspaceLink('connected-accounts')).toHaveTextContent('Connected accounts') - expect(container.querySelector('a[href^="/o/"]')).toBeNull() - expectWorkspaceLinks() - } - ) + expectOrganizationLink() + }) - it('keeps existing settings when an older host context has no features object', () => { + it('links to organization settings when an older host context has no features object', () => { hostContext.features = undefined renderSidebar() - expect(workspaceLink('connected-accounts')).toBeNull() - - expect(workspaceLink('organization')).toHaveTextContent('Members') - expect(workspaceLink('billing')).toHaveTextContent('Subscription') - expect(container.querySelector('a[href^="/o/"]')).toBeNull() - expectWorkspaceLinks() + expectOrganizationLink() }) - it.each(['admin', 'member'] as const)( - 'replaces organization entries with one host organization link for a %s when enabled', - (role) => { - hostContext = makeHostContext(role, true) - renderSidebar() - - const links = container.querySelectorAll('a[href^="/o/"]') - expect(links).toHaveLength(1) - expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members') - expect(links[0]).toHaveTextContent('Organization') - for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { - expect(workspaceLink(section)).toBeNull() - } - expectWorkspaceLinks() - } - ) - - it('keeps the member roster while preserving admin-only settings restrictions when disabled', () => { - hostContext = makeHostContext('member', false) + it('keeps organization settings reachable on self-hosted deployments without billing', () => { + hostContext.deployment = { ...deployment, hosted: false, billingEnabled: false } renderSidebar() - expect(workspaceLink('organization')).toHaveTextContent('Members') - for (const section of ['billing', 'usage', 'sso', 'connected-accounts']) { - expect(workspaceLink(section)).toBeNull() - } - expect(container.querySelector('a[href^="/o/"]')).toBeNull() - expectWorkspaceLinks() + expectOrganizationLink() }) - it('preserves plan restrictions without hiding the billing recovery link when disabled', () => { + it('keeps organization settings reachable when billing is blocked', () => { hostContext.ownerBilling.billingBlocked = true hostContext.ownerBilling.billingBlockedReason = 'payment_failed' renderSidebar() - expect(workspaceLink('billing')).toHaveTextContent('Subscription') - for (const section of ['organization', 'usage', 'sso']) { - expect(workspaceLink(section)).toBeNull() - } - expectWorkspaceLinks() + expectOrganizationLink() }) it.each([false, true])( - 'keeps external workspace admins out of organization settings when rollout is %s', + 'keeps external workspace admins out of organization settings with Search %s', (enabled) => { hostContext = makeHostContext('external', enabled) renderSidebar() 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..351cef2207a 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 @@ -130,10 +130,9 @@ export function SettingsSidebar({ const userId = session?.user?.id const isOrgAdminOrOwner = hostContext.viewer.isHostOrganizationAdmin - const organizationSettingsId = - hostContext.features?.organizationSearch && hostContext.viewer.isHostOrganizationMember - ? hostContext.hostOrganizationId - : null + const organizationSettingsId = hostContext.viewer.isHostOrganizationMember + ? hostContext.hostOrganizationId + : null const subscriptionAccess = getSubscriptionAccessState(hostContext.ownerBilling) const inboxEntitled = inboxConfig?.entitled ?? false const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess @@ -149,18 +148,9 @@ export function SettingsSidebar({ const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { - if (item.id === 'connected-accounts') { - return Boolean( - hostContext.hostOrganizationId && - isOrgAdminOrOwner && - hostContext.features?.credentialGroups && - !hostContext.features?.organizationSearch - ) - } if ( - hostContext.hostOrganizationId && - ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && - (organizationSettingsId || !hostContext.viewer.isHostOrganizationMember) + item.id === 'connected-accounts' || + (hostContext.hostOrganizationId && ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id)) ) { return false } @@ -271,7 +261,6 @@ export function SettingsSidebar({ hostContext, userId, isOrgAdminOrOwner, - organizationSettingsId, isSSOProviderOwner, ssoProvidersData?.providers?.length, permissionConfig, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index 2ba35bebcef..84aa6870477 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -404,10 +404,10 @@ describe('WorkspaceHeader workspace switcher highlight', () => { }) describe('WorkspaceHeader context navigation', () => { - it('links to the current host organization for enrolled members', () => { + it.each([true, false, undefined])('links to the host organization with Search %s', (enabled) => { hostContext.hostOrganizationId = 'host-org' hostContext.viewer.isHostOrganizationMember = true - hostContext.features.organizationSearch = true + hostContext.features.organizationSearch = enabled render() expect(document.querySelector('a[href="/o/host-org"]')).toHaveTextContent( 'Back to organization' @@ -417,8 +417,8 @@ describe('WorkspaceHeader context navigation', () => { it.each([ { org: null, member: true, enabled: true }, { org: 'host-org', member: false, enabled: true }, - { org: 'host-org', member: true, enabled: false }, - { org: 'host-org', member: true, enabled: undefined }, + { org: null, member: true, enabled: false }, + { org: 'host-org', member: false, enabled: false }, ])('hides inaccessible organization navigation: %j', ({ org, member, enabled }) => { hostContext.hostOrganizationId = org hostContext.viewer.isHostOrganizationMember = member diff --git a/apps/sim/lib/workspaces/organization-navigation.ts b/apps/sim/lib/workspaces/organization-navigation.ts index 758bf66f2d1..9aeb0973388 100644 --- a/apps/sim/lib/workspaces/organization-navigation.ts +++ b/apps/sim/lib/workspaces/organization-navigation.ts @@ -3,9 +3,7 @@ import { organizationRoutes } from '@/lib/navigation/paths' /** Returns the workspace's organization destination only when the viewer can enter it. */ export function getWorkspaceOrganizationHref(hostContext: WorkspaceHostContext): string | null { - return hostContext.hostOrganizationId && - hostContext.viewer.isHostOrganizationMember && - hostContext.features?.organizationSearch + return hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationMember ? organizationRoutes(hostContext.hostOrganizationId).root : null } From 6187085536a2012c3f4773416fc720dd11341254 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 15 Sep 2026 15:02:51 -0700 Subject: [PATCH 2/2] fix(settings): keep organization settings inline without Sim Search --- .../app/o/[organizationId]/home/page.test.tsx | 6 +- .../app/o/[organizationId]/layout.test.tsx | 46 +++---- apps/sim/app/o/[organizationId]/layout.tsx | 12 +- apps/sim/app/o/[organizationId]/page.tsx | 4 +- .../app/o/[organizationId]/prefetch.test.ts | 14 +-- apps/sim/app/o/[organizationId]/prefetch.ts | 26 ++-- .../settings/[section]/page.test.tsx | 24 ++-- .../[workspaceId]/settings/[section]/page.tsx | 2 +- .../settings/[section]/settings.test.tsx | 65 ++++++++++ .../settings/[section]/settings.tsx | 4 +- .../team-management/team-management.test.tsx | 29 +++++ .../team-management/team-management.tsx | 11 +- .../settings-sidebar.test.tsx | 116 +++++++++++++----- .../settings-sidebar/settings-sidebar.tsx | 32 +++-- .../workspace-header.test.tsx | 8 +- apps/sim/components/settings/navigation.ts | 23 +--- .../workspace-section-access.test.ts | 28 +++++ .../application/workspace-section-access.ts | 5 +- .../lib/workspaces/organization-navigation.ts | 4 +- 19 files changed, 300 insertions(+), 159 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.test.tsx diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx index 8c2c68229e2..c5b4d414c24 100644 --- a/apps/sim/app/o/[organizationId]/home/page.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx @@ -101,9 +101,11 @@ describe('organization Search page gates', () => { expect(mocks.context).not.toHaveBeenCalled() }) - it('lands the organization entry on Members settings when Search is disabled', async () => { + it('returns the organization entry to workspace settings when Search is disabled', async () => { mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } }) - await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/settings/members') + await expect(OrganizationPage({ params })).rejects.toThrow( + 'redirect:/workspace?redirect=settings' + ) expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer') }) diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx index 9bd9394de1b..0ad7389f7ac 100644 --- a/apps/sim/app/o/[organizationId]/layout.test.tsx +++ b/apps/sim/app/o/[organizationId]/layout.test.tsx @@ -123,8 +123,7 @@ describe('OrganizationLayout', () => { {}, 'org-1', { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, - 'active-org', - true + 'active-org' ) expect(html).toContain('Organization child') expect(mockUseMothershipChatEvents).toHaveBeenCalledWith( @@ -159,15 +158,14 @@ describe('OrganizationLayout', () => { {}, 'org-1', { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, - null, - true + null ) expect(html).toContain('Impersonating QA Member (member@example.com)') expect(html).toContain('Stop impersonating') expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child')) }) - it('uses the impersonated member to open settings when Search is disabled', async () => { + it('does not use the impersonating admin to enter an organization outside the rollout', async () => { mockGetSession.mockResolvedValue({ user: { id: 'customer-member' }, session: { id: 'session-1', impersonatedBy: 'platform-admin' }, @@ -177,24 +175,18 @@ describe('OrganizationLayout', () => { searchAccess: { memberScoped: false, sourceMirrored: false }, }) - const html = renderToStaticMarkup( - await OrganizationLayout({ + await expect( + OrganizationLayout({ children:
Organization child
, params: Promise.resolve({ organizationId: 'customer-org' }), }) - ) + ).rejects.toThrow('redirect:/workspace?redirect=settings') expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith( 'customer-org', 'customer-member' ) - expect(html).toContain('Organization child') - expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( - {}, - 'customer-org', - { kind: 'session', userId: 'customer-member', sessionId: 'session-1' }, - null, - false - ) + expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders an explicit denial for a non-member without the surface', async () => { @@ -213,7 +205,7 @@ describe('OrganizationLayout', () => { }) it.each(['owner', 'admin', 'member'])( - 'renders organization settings for a %s when Search is disabled', + 'returns %s viewers outside the rollout to workspace settings before rendering org chrome', async (role) => { mockGetOrganizationSurfaceContext.mockResolvedValue({ ...SURFACE_CONTEXT, @@ -221,24 +213,14 @@ describe('OrganizationLayout', () => { searchAccess: { memberScoped: false, sourceMirrored: true }, }) - const html = renderToStaticMarkup( - await OrganizationLayout({ + await expect( + OrganizationLayout({ children:
Organization settings
, params: Promise.resolve({ organizationId: 'org-1' }), }) - ) - expect(html).toContain('Organization settings') - expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( - {}, - 'org-1', - { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, - 'active-org', - false - ) - expect(mockUseMothershipChatEvents).toHaveBeenCalledWith( - undefined, - SURFACE_CONTEXT.deployment.chatEnabled - ) + ).rejects.toThrow('redirect:/workspace?redirect=settings') + expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() } ) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 520a50c4796..fb4b66e85bd 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -3,7 +3,7 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' -import { organizationRoutes } from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -18,8 +18,9 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/ /** * The organization surface: the viewer's own view of one organization, outside - * any workspace. Requires membership; Search availability is checked by the - * individual pages and controls that use it. + * any workspace. Requires membership and the organization's Search rollout. + * Non-members get an explicit denial; members outside the rollout retain + * workspace settings, including when following a saved organization link. */ export default async function OrganizationLayout({ children, @@ -47,12 +48,13 @@ export default async function OrganizationLayout({ if (!context) { return } + if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + await prefetchOrganizationSidebar( queryClient, organizationId, { kind: 'session', userId: session.user.id, sessionId: session.session.id }, - getActiveOrganizationId(session), - context.searchAccess.memberScoped + getActiveOrganizationId(session) ) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx index cc544bd0371..fd6726efd4f 100644 --- a/apps/sim/app/o/[organizationId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/page.tsx @@ -1,6 +1,6 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { organizationRoutes } from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -17,5 +17,5 @@ export default async function OrganizationPage({ } const context = await getOrganizationSurfaceContext(organizationId, session.user.id) if (!context) notFound() - redirect(context.searchAccess.memberScoped ? routes.home : routes.settingsSection('members')) + redirect(context.searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH) } diff --git a/apps/sim/app/o/[organizationId]/prefetch.test.ts b/apps/sim/app/o/[organizationId]/prefetch.test.ts index e53b4443d68..679b8c88e24 100644 --- a/apps/sim/app/o/[organizationId]/prefetch.test.ts +++ b/apps/sim/app/o/[organizationId]/prefetch.test.ts @@ -61,8 +61,8 @@ function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } -function prefetch(client: QueryClient, searchAvailable = true) { - return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org', searchAvailable) +function prefetch(client: QueryClient) { + return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org') } describe('organization sidebar hydration', () => { @@ -132,16 +132,6 @@ describe('organization sidebar hydration', () => { expect(dehydrate(client).queries).toHaveLength(3) }) - it('hydrates workspace and profile data without loading Search chats when Search is disabled', async () => { - const client = makeClient() - await prefetch(client, false) - - expect(mockListOrganizationChats).not.toHaveBeenCalled() - expect(client.getQueryState(CHAT_KEY)).toBeUndefined() - expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) - expect(client.getQueryData(userProfileKeys.profile())).toMatchObject({ name: 'Ada' }) - }) - it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => { mockListOrganizationChats.mockResolvedValue([]) mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] }) diff --git a/apps/sim/app/o/[organizationId]/prefetch.ts b/apps/sim/app/o/[organizationId]/prefetch.ts index 572160c941c..0ed2be1f62f 100644 --- a/apps/sim/app/o/[organizationId]/prefetch.ts +++ b/apps/sim/app/o/[organizationId]/prefetch.ts @@ -18,22 +18,20 @@ export async function prefetchOrganizationSidebar( queryClient: QueryClient, organizationId: string, principal: SessionPrincipal, - activeOrganizationId: string | null, - searchAvailable: boolean + activeOrganizationId: string | null ): Promise { await Promise.all([ - searchAvailable && - queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), - queryFn: async () => { - const chats = await listOrganizationChats.execute({ - principal, - input: { organizationId, scope: 'active' }, - }) - return chats.map(mapChat) - }, - staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, - }), + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), + queryFn: async () => { + const chats = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope: 'active' }, + }) + return chats.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), seedWorkspaceList(queryClient, principal.userId, activeOrganizationId), prefetchUserProfile(queryClient, principal.userId), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 5a00bd8b823..bfd7f13b9f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -112,17 +112,17 @@ describe('WorkspaceSettingsSectionPage', () => { }) it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))( - 'routes %s to organization %s when Search is disabled', - async (section, organizationSection) => { + 'keeps %s in the workspace outside the organization rollout', + async (section) => { mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features: { organizationSearch: false, knowledgeMemberAccess: true }, }) - await expect(WorkspaceSettingsSectionPage(pageProps(section))).rejects.toThrow( - `NEXT_REDIRECT:/o/org-target/settings/${organizationSection}` - ) - expect(mockSectionPrefetch).not.toHaveBeenCalled() + const element = await WorkspaceSettingsSectionPage(pageProps(section)) + + expect(element).toBeTruthy() + expect(mockRedirect).not.toHaveBeenCalled() expect(mockAuthorizeSection).toHaveBeenCalledWith({ workspaceId: 'workspace-b', userId: 'viewer-a', @@ -132,19 +132,19 @@ describe('WorkspaceSettingsSectionPage', () => { ) it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])( - 'routes settings to the organization when older host context omits Search availability', + 'keeps settings in the workspace when older host context omits the org rollout', async (features) => { mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features }) - await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow( - 'NEXT_REDIRECT:/o/org-target/settings/billing' - ) - expect(mockSectionPrefetch).not.toHaveBeenCalled() + await WorkspaceSettingsSectionPage(pageProps('billing')) + + expect(mockRedirect).not.toHaveBeenCalled() + expect(mockSectionPrefetch).toHaveBeenCalledTimes(1) } ) it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))( - 'routes %s to organization %s when Search is enabled', + 'routes %s to organization %s only within the organization rollout', async (section, organizationSection) => { mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 34ce91bc3e3..04ba51f88e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -60,7 +60,7 @@ export default async function WorkspaceSettingsSectionPage({ const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[parsed] if (organizationSection) { const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) - if (hostContext?.hostOrganizationId) { + if (hostContext?.hostOrganizationId && hostContext.features?.organizationSearch) { const query = new URLSearchParams() for (const [key, value] of Object.entries((await searchParams) ?? {})) { for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) { 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 dec13a96617..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 @@ -185,24 +185,13 @@ function expectWorkspaceLinks() { expect(workspaceLink('secrets')).toHaveTextContent('Secrets') } -function expectOrganizationLink() { - const links = container.querySelectorAll('a[href^="/o/"]') - expect(links).toHaveLength(1) - expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members') - expect(links[0]).toHaveTextContent('Organization') - for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { - expect(workspaceLink(section)).toBeNull() - } - expectWorkspaceLinks() -} - -describe('workspace SettingsSidebar organization navigation', () => { +describe('workspace SettingsSidebar organization rollout', () => { it('hides Connected accounts when credential groups are disabled', () => { hostContext.features = { ...hostContext.features!, credentialGroups: false } renderSidebar() expect(workspaceLink('connected-accounts')).toBeNull() - expectOrganizationLink() + expectWorkspaceLinks() }) it('does not offer organization accounts in a personal workspace', () => { @@ -210,47 +199,106 @@ describe('workspace SettingsSidebar organization navigation', () => { renderSidebar() expect(workspaceLink('connected-accounts')).toBeNull() - expect(container.querySelector('a[href^="/o/"]')).toBeNull() + expect(workspaceLink('organization')).toBeNull() }) - it.each([ - { role: 'admin', search: true }, - { role: 'admin', search: false }, - { role: 'admin', search: undefined }, - { role: 'member', search: true }, - { role: 'member', search: false }, - { role: 'member', search: undefined }, - ] as const)('links a $role to organization settings with Search $search', ({ role, search }) => { - hostContext = makeHostContext(role, search) - renderSidebar() + it.each([false, undefined])( + 'keeps organization settings in the workspace for an admin when rollout is %s', + (enabled) => { + hostContext = makeHostContext('admin', enabled) + renderSidebar() - expectOrganizationLink() - }) + expect(workspaceLink('organization')).toHaveTextContent('Members') + expect(workspaceLink('billing')).toHaveTextContent('Subscription') + expect(workspaceLink('usage')).toHaveTextContent('Usage tracking') + expect(workspaceLink('sso')).toHaveTextContent('Single sign-on') + expect(workspaceLink('connected-accounts')).toHaveTextContent('Connected accounts') + expect(container.querySelector('a[href^="/o/"]')).toBeNull() + expectWorkspaceLinks() + } + ) - it('links to organization settings when an older host context has no features object', () => { + it('keeps existing settings when an older host context has no features object', () => { hostContext.features = undefined renderSidebar() - expectOrganizationLink() + expect(workspaceLink('connected-accounts')).toBeNull() + + expect(workspaceLink('organization')).toHaveTextContent('Members') + expect(workspaceLink('billing')).toHaveTextContent('Subscription') + expect(container.querySelector('a[href^="/o/"]')).toBeNull() + expectWorkspaceLinks() }) - it('keeps organization settings reachable on self-hosted deployments without billing', () => { - hostContext.deployment = { ...deployment, hosted: false, billingEnabled: false } + it.each(['admin', 'member'] as const)( + 'replaces organization entries with one host organization link for a %s when enabled', + (role) => { + hostContext = makeHostContext(role, true) + renderSidebar() + + const links = container.querySelectorAll('a[href^="/o/"]') + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members') + expect(links[0]).toHaveTextContent('Organization') + for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { + expect(workspaceLink(section)).toBeNull() + } + expectWorkspaceLinks() + } + ) + + it('keeps the member roster while preserving admin-only settings restrictions when disabled', () => { + hostContext = makeHostContext('member', false) renderSidebar() - expectOrganizationLink() + expect(workspaceLink('organization')).toHaveTextContent('Members') + for (const section of ['billing', 'usage', 'sso', 'connected-accounts']) { + expect(workspaceLink(section)).toBeNull() + } + expect(container.querySelector('a[href^="/o/"]')).toBeNull() + expectWorkspaceLinks() }) - it('keeps organization settings reachable when billing is blocked', () => { + it('preserves plan restrictions without hiding the billing recovery link when disabled', () => { hostContext.ownerBilling.billingBlocked = true hostContext.ownerBilling.billingBlockedReason = 'payment_failed' renderSidebar() - expectOrganizationLink() + expect(workspaceLink('billing')).toHaveTextContent('Subscription') + 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 with Search %s', + 'keeps external workspace admins out of organization settings when rollout is %s', (enabled) => { hostContext = makeHostContext('external', enabled) renderSidebar() 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 351cef2207a..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 @@ -130,12 +130,12 @@ export function SettingsSidebar({ const userId = session?.user?.id const isOrgAdminOrOwner = hostContext.viewer.isHostOrganizationAdmin - const organizationSettingsId = hostContext.viewer.isHostOrganizationMember - ? hostContext.hostOrganizationId - : null + const organizationSettingsId = + hostContext.features?.organizationSearch && hostContext.viewer.isHostOrganizationMember + ? hostContext.hostOrganizationId + : null const subscriptionAccess = getSubscriptionAccessState(hostContext.ownerBilling) const inboxEntitled = inboxConfig?.entitled ?? false - const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess const isEnterprisePlan = subscriptionAccess.isEnterprise @@ -148,12 +148,26 @@ export function SettingsSidebar({ const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { + if (item.id === 'connected-accounts') { + return Boolean( + hostContext.hostOrganizationId && + isOrgAdminOrOwner && + hostContext.features?.credentialGroups && + !hostContext.features?.organizationSearch + ) + } if ( - item.id === 'connected-accounts' || - (hostContext.hostOrganizationId && ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id)) + hostContext.hostOrganizationId && + ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && + (organizationSettingsId || !hostContext.viewer.isHostOrganizationMember) ) { return false } + if (item.id === 'organization') { + return Boolean( + hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationMember + ) + } if (item.requiresSelfHosted && hosted) { return false } @@ -218,10 +232,6 @@ export function SettingsSidebar({ const orgAdminSatisfied = isOrgAdminOrOwner || item.allowNonOrgAdmin - if (item.requiresTeam && (!hasTeamPlan || !orgAdminSatisfied)) { - return false - } - if ( item.requiresEnterprise && (!hasEnterprisePlan || !orgAdminSatisfied) && @@ -254,13 +264,13 @@ export function SettingsSidebar({ deployment, hosted, billingEnabled, - hasTeamPlan, hasEnterprisePlan, isEnterprisePlan, subscriptionAccess.hasUsableMaxAccess, hostContext, userId, isOrgAdminOrOwner, + organizationSettingsId, isSSOProviderOwner, ssoProvidersData?.providers?.length, permissionConfig, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index 84aa6870477..2ba35bebcef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -404,10 +404,10 @@ describe('WorkspaceHeader workspace switcher highlight', () => { }) describe('WorkspaceHeader context navigation', () => { - it.each([true, false, undefined])('links to the host organization with Search %s', (enabled) => { + it('links to the current host organization for enrolled members', () => { hostContext.hostOrganizationId = 'host-org' hostContext.viewer.isHostOrganizationMember = true - hostContext.features.organizationSearch = enabled + hostContext.features.organizationSearch = true render() expect(document.querySelector('a[href="/o/host-org"]')).toHaveTextContent( 'Back to organization' @@ -417,8 +417,8 @@ describe('WorkspaceHeader context navigation', () => { it.each([ { org: null, member: true, enabled: true }, { org: 'host-org', member: false, enabled: true }, - { org: null, member: true, enabled: false }, - { org: 'host-org', member: false, enabled: false }, + { org: 'host-org', member: true, enabled: false }, + { org: 'host-org', member: true, enabled: undefined }, ])('hides inaccessible organization navigation: %j', ({ org, member, enabled }) => { hostContext.hostOrganizationId = org hostContext.viewer.isHostOrganizationMember = member 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) { diff --git a/apps/sim/lib/workspaces/organization-navigation.ts b/apps/sim/lib/workspaces/organization-navigation.ts index 9aeb0973388..758bf66f2d1 100644 --- a/apps/sim/lib/workspaces/organization-navigation.ts +++ b/apps/sim/lib/workspaces/organization-navigation.ts @@ -3,7 +3,9 @@ import { organizationRoutes } from '@/lib/navigation/paths' /** Returns the workspace's organization destination only when the viewer can enter it. */ export function getWorkspaceOrganizationHref(hostContext: WorkspaceHostContext): string | null { - return hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationMember + return hostContext.hostOrganizationId && + hostContext.viewer.isHostOrganizationMember && + hostContext.features?.organizationSearch ? organizationRoutes(hostContext.hostOrganizationId).root : null }