diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx
index dcb449256f8..4bbd38322b6 100644
--- a/apps/sim/app/o/[organizationId]/home/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx
@@ -46,7 +46,6 @@ describe('organization Search page gates', () => {
['Home', () => OrganizationHomePage({ params })],
['Search', () => OrganizationSearchPage({ params })],
['chat', () => OrganizationChatPage({ params })],
- ['organization entry', () => OrganizationPage({ params })],
] as const)('redirects %s to workspace settings when Search is disabled', async (_name, open) => {
mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false, sourceMirrored: true } })
await expect(open()).rejects.toThrow('redirect:/workspace?redirect=settings')
@@ -58,6 +57,7 @@ describe('organization Search page gates', () => {
['Home', () => OrganizationHomePage({ params })],
['Search', () => OrganizationSearchPage({ params })],
['chat', () => OrganizationChatPage({ params })],
+ ['organization entry', () => OrganizationPage({ params })],
] as const)('denies %s to nonmembers before loading content', async (_name, open) => {
mocks.context.mockResolvedValue(null)
await expect(open()).rejects.toThrow('not-found')
@@ -90,6 +90,21 @@ describe('organization Search page gates', () => {
await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/home')
})
+ it('preserves the organization entry through sign-in', async () => {
+ authMockFns.mockGetSession.mockResolvedValue(null)
+
+ await expect(OrganizationPage({ params })).rejects.toThrow(
+ 'redirect:/login?callbackUrl=%2Fo%2Forg-1'
+ )
+ expect(mocks.context).not.toHaveBeenCalled()
+ })
+
+ it('keeps the organization entry in organization settings when Search is disabled', async () => {
+ mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } })
+ await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/settings/members')
+ expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer')
+ })
+
it('propagates availability failures instead of rendering the Assistant', async () => {
mocks.context.mockRejectedValue(new Error('Availability unavailable'))
await expect(OrganizationHomePage({ params })).rejects.toThrow('Availability unavailable')
diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx
index f19d54169a7..928ea29c200 100644
--- a/apps/sim/app/o/[organizationId]/layout.test.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.test.tsx
@@ -32,7 +32,9 @@ vi.mock('next/headers', () => ({
}))
vi.mock('next/navigation', () => ({
- redirect: vi.fn(),
+ redirect: (path: string) => {
+ throw new Error(`redirect:${path}`)
+ },
}))
vi.mock('@/lib/organizations/surface', () => ({
@@ -66,6 +68,18 @@ describe('OrganizationLayout', () => {
mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } })
})
+ it('returns signed-out visitors to the organization entry after sign-in', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ await expect(
+ OrganizationLayout({
+ children: null,
+ params: Promise.resolve({ organizationId: 'org-1' }),
+ })
+ ).rejects.toThrow('redirect:/login?callbackUrl=%2Fo%2Forg-1')
+ expect(mockGetOrganizationSurfaceContext).not.toHaveBeenCalled()
+ })
+
it('renders the surface for a member and seeds the chrome from the collapse cookie', async () => {
mockGetOrganizationSurfaceContext.mockResolvedValue(SURFACE_CONTEXT)
diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
index 6b48f2feda3..afca4d4025f 100644
--- a/apps/sim/app/o/[organizationId]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -31,7 +31,7 @@ export default async function OrganizationLayout({
if (!session?.user) {
redirect(
buildAuthCrossLink('/login', {
- callbackUrl: organizationRoutes(organizationId).home,
+ callbackUrl: organizationRoutes(organizationId).root,
isInviteFlow: false,
})
)
diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx
index 5ea08a88e4e..cc544bd0371 100644
--- a/apps/sim/app/o/[organizationId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/page.tsx
@@ -1,7 +1,8 @@
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'
export default async function OrganizationPage({
params,
@@ -9,10 +10,12 @@ export default async function OrganizationPage({
params: Promise<{ organizationId: string }>
}) {
const { organizationId } = await params
+ const routes = organizationRoutes(organizationId)
const session = await getSession()
- if (!session?.user?.id) notFound()
+ if (!session?.user?.id) {
+ redirect(buildAuthCrossLink('/login', { callbackUrl: routes.root, isInviteFlow: false }))
+ }
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
if (!context) notFound()
- const routes = organizationRoutes(organizationId)
- 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]/settings/[section]/layout.test.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx
new file mode 100644
index 00000000000..2f7eca930aa
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx
@@ -0,0 +1,39 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('next/navigation', () => ({
+ redirect: (path: string) => {
+ throw new Error(`redirect:${path}`)
+ },
+ notFound: () => {
+ throw new Error('not-found')
+ },
+}))
+vi.mock('@/components/settings/settings-header', () => ({
+ SettingsHeaderProvider: () => null,
+ SettingsHeaderShell: () => null,
+}))
+
+import OrganizationSettingsSectionLayout from '@/app/o/[organizationId]/settings/[section]/layout'
+
+describe('organization settings section routing', () => {
+ it('redirects legacy authorized-app links before the section loading boundary', async () => {
+ await expect(
+ OrganizationSettingsSectionLayout({
+ children: null,
+ params: Promise.resolve({ organizationId: 'target-org', section: 'authorized-apps' }),
+ })
+ ).rejects.toThrow('redirect:/o/target-org/settings/general?view=authorized-apps')
+ })
+
+ it('rejects unknown sections', async () => {
+ await expect(
+ OrganizationSettingsSectionLayout({
+ children: null,
+ params: Promise.resolve({ organizationId: 'target-org', section: 'unknown' }),
+ })
+ ).rejects.toThrow('not-found')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
index 1874d5fc50f..4e0889175a7 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
@@ -1,23 +1,29 @@
import type { ReactNode } from 'react'
-import { notFound } from 'next/navigation'
+import { notFound, redirect } from 'next/navigation'
import {
getSettingsSectionMeta,
ORGANIZATION_SETTINGS_ITEMS,
toSettingsHeaderMeta,
} from '@/components/settings/navigation'
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import { organizationRoutes } from '@/lib/navigation/paths'
import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
interface OrganizationSettingsSectionLayoutProps {
children: ReactNode
- params: Promise<{ section: string }>
+ params: Promise<{ organizationId: string; section: string }>
}
export default async function OrganizationSettingsSectionLayout({
children,
params,
}: OrganizationSettingsSectionLayoutProps) {
- const { section } = await params
+ const { organizationId, section } = await params
+ if (section === 'authorized-apps') {
+ redirect(
+ `${organizationRoutes(organizationId).settingsSection('general')}?view=authorized-apps`
+ )
+ }
const resolved = resolveOrganizationSurfaceSection(section)
const meta =
resolved?.plane === 'organization'
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx
new file mode 100644
index 00000000000..8c85b14beed
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx
@@ -0,0 +1,4 @@
+/** Lets the section layout show its heading while authorization and content finish loading. */
+export default function OrganizationSettingsSectionLoading() {
+ return null
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
index 3ad60782e4f..50d37a129be 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
@@ -45,9 +45,6 @@ export default async function OrganizationSettingsSectionPage({
}: OrganizationSettingsSectionPageProps) {
const { organizationId, section } = await params
const routes = organizationRoutes(organizationId)
- if (section === 'authorized-apps') {
- redirect(`${routes.settingsSection('general')}?view=authorized-apps`)
- }
const resolved = resolveOrganizationSurfaceSection(section)
if (!resolved) notFound()
const session = await getSession()
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
index 28c56368897..84b1cde9f3b 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
@@ -8,9 +8,22 @@ import {
} from '@/components/settings/navigation'
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
-import { OrganizationSearchMcp } from '@/app/o/[organizationId]/settings/components/organization-search-mcp'
-import { OrganizationConnectedAccounts } from '@/ee/credential-groups/components/organization-connected-accounts'
+
+const OrganizationIntegrationsSettings = dynamic(() =>
+ import(
+ '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
+ ).then((m) => m.OrganizationIntegrationsSettings)
+)
+const OrganizationSearchMcp = dynamic(() =>
+ import('@/app/o/[organizationId]/settings/components/organization-search-mcp').then(
+ (m) => m.OrganizationSearchMcp
+ )
+)
+const OrganizationConnectedAccounts = dynamic(() =>
+ import('@/ee/credential-groups/components/organization-connected-accounts').then(
+ (m) => m.OrganizationConnectedAccounts
+ )
+)
const TeamManagement = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.test.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.test.tsx
new file mode 100644
index 00000000000..8660ee4c298
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.test.tsx
@@ -0,0 +1,197 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson, context } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+ context: {
+ organization: { id: 'org-1' },
+ viewer: { isAdmin: true },
+ connectedAccountsAvailable: true,
+ searchAccess: { memberScoped: true },
+ settingsFeatures: {
+ billingEnabled: true,
+ hosted: true,
+ hasEnterprisePlan: true,
+ selfHosted: {},
+ },
+ },
+}))
+
+vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: () => context,
+}))
+vi.mock('@/components/settings/settings-sidebar', () => ({
+ SettingsSidebar: ({ items }: { items: { id: string; label: string }[] }) => (
+
+ ),
+}))
+
+import { ApiClientError } from '@/lib/api/client/errors'
+import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar'
+import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
+
+const activeEnterpriseSummary = {
+ success: true,
+ data: {
+ subscriptionState: 'active',
+ subscriptionPlan: 'enterprise',
+ subscriptionStatus: 'active',
+ billingBlocked: false,
+ },
+}
+
+let root: Root
+let container: HTMLDivElement
+let queryClient: QueryClient
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ mockRequestJson.mockImplementation(() => new Promise(() => {}))
+ context.viewer.isAdmin = true
+ context.settingsFeatures.hosted = true
+ context.settingsFeatures.billingEnabled = true
+ context.settingsFeatures.hasEnterprisePlan = true
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ queryClient = new QueryClient()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ queryClient.clear()
+ focusManager.setFocused(undefined)
+ vi.useRealTimers()
+})
+
+async function renderSidebar() {
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1)
+ })
+}
+
+describe('organization settings navigation first paint', () => {
+ it('renders enterprise sections while only the lightweight summary refreshes in the background', async () => {
+ await renderSidebar()
+
+ expect(container).toHaveTextContent('Audit logs')
+ expect(container).toHaveTextContent('Data retention')
+ expect(container).toHaveTextContent('Integrations')
+ expect(mockRequestJson).toHaveBeenCalledTimes(1)
+ expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/organizations/[id]/billing-summary')
+ })
+
+ it('keeps admin sections hidden from ordinary members even with enterprise features', async () => {
+ context.viewer.isAdmin = false
+ await renderSidebar()
+
+ expect(container).toHaveTextContent('Search MCP')
+ expect(container).not.toHaveTextContent('Audit logs')
+ expect(container).not.toHaveTextContent('Subscription')
+ expect(mockRequestJson).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { hosted: false, billingEnabled: false },
+ { hosted: true, billingEnabled: false },
+ ])(
+ 'does not refresh billing when hosted=$hosted and billingEnabled=$billingEnabled',
+ async (deployment) => {
+ Object.assign(context.settingsFeatures, deployment)
+ await renderSidebar()
+
+ expect(mockRequestJson).not.toHaveBeenCalled()
+ expect(container).not.toHaveTextContent('Subscription')
+ }
+ )
+
+ it('refreshes entitlement changes after returning from the billing portal', async () => {
+ mockRequestJson.mockResolvedValue(activeEnterpriseSummary)
+ await renderSidebar()
+ expect(container).toHaveTextContent('Audit logs')
+
+ mockRequestJson.mockResolvedValue({
+ ...activeEnterpriseSummary,
+ data: { ...activeEnterpriseSummary.data, subscriptionPlan: 'team' },
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(31_000)
+ focusManager.setFocused(false)
+ focusManager.setFocused(true)
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(container).not.toHaveTextContent('Audit logs')
+
+ mockRequestJson.mockResolvedValue(activeEnterpriseSummary)
+ await act(async () => {
+ await queryClient.invalidateQueries({ queryKey: organizationKeys.billingSummary('org-1') })
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ expect(container).toHaveTextContent('Audit logs')
+ })
+
+ it.each([
+ { subscriptionState: 'lapsed', subscriptionStatus: 'canceled', billingBlocked: false },
+ { subscriptionState: 'active', subscriptionStatus: 'past_due', billingBlocked: false },
+ { subscriptionState: 'active', subscriptionStatus: 'active', billingBlocked: true },
+ ])(
+ 'hides unusable enterprise plans: $subscriptionStatus, blocked=$billingBlocked',
+ async (state) => {
+ mockRequestJson.mockResolvedValue({
+ ...activeEnterpriseSummary,
+ data: { ...activeEnterpriseSummary.data, ...state },
+ })
+ await renderSidebar()
+
+ expect(container).not.toHaveTextContent('Audit logs')
+ expect(container).toHaveTextContent('Subscription')
+ }
+ )
+
+ it('hides admin sections if a refresh revokes billing access, even with cached data', async () => {
+ mockRequestJson.mockResolvedValue(activeEnterpriseSummary)
+ await renderSidebar()
+ expect(container).toHaveTextContent('Audit logs')
+
+ mockRequestJson.mockRejectedValue(
+ new ApiClientError({ status: 403, message: 'Forbidden', body: null })
+ )
+ await act(async () => {
+ await queryClient.invalidateQueries({ queryKey: organizationKeys.billingSummary('org-1') })
+ await vi.advanceTimersByTimeAsync(1)
+ })
+
+ expect(container).not.toHaveTextContent('Audit logs')
+ expect(container).not.toHaveTextContent('Subscription')
+ expect(container).toHaveTextContent('Search MCP')
+ })
+
+ it('does not display enterprise sections for a team plan', async () => {
+ context.settingsFeatures.hasEnterprisePlan = false
+ await renderSidebar()
+
+ expect(container).toHaveTextContent('Subscription')
+ expect(container).not.toHaveTextContent('Audit logs')
+ expect(mockRequestJson).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
index f8efce6dccd..ab5d7c8de34 100644
--- a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
@@ -1,20 +1,20 @@
'use client'
+import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
-import {
- getOrganizationSettingsFeatures,
- ORGANIZATION_SETTINGS_GROUPS,
-} from '@/components/settings/navigation'
+import { ORGANIZATION_SETTINGS_GROUPS } from '@/components/settings/navigation'
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
+import { isApiClientError } from '@/lib/api/client/errors'
import { isEnterprise } from '@/lib/billing/plan-helpers'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import {
organizationSurfaceSettingsNavigation,
resolveOrganizationSurfaceSection,
} from '@/app/o/[organizationId]/settings/navigation'
-import { useOrganizationBilling } from '@/hooks/queries/organization'
+import { warmOrganizationSettingsSectionQuery } from '@/app/o/[organizationId]/settings/settings-query-warmers'
+import { useOrganizationBillingSummary } from '@/hooks/queries/organization-billing-summary'
interface OrganizationSettingsSidebarProps {
isCollapsed: boolean
@@ -22,17 +22,27 @@ interface OrganizationSettingsSidebarProps {
}
export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarProps) {
- const { organization, viewer, connectedAccountsAvailable, searchAccess } =
+ const { organization, viewer, connectedAccountsAvailable, searchAccess, settingsFeatures } =
useOrganizationContext()
const pathname = usePathname()
- const deployment = useDeploymentShape()
- const { data: billing } = useOrganizationBilling(organization.id, {
- enabled: viewer.isAdmin && deployment.hosted,
+ const queryClient = useQueryClient()
+ const refreshPlan = viewer.isAdmin && settingsFeatures.hosted && settingsFeatures.billingEnabled
+ const { data: summary, error } = useOrganizationBillingSummary(organization.id, {
+ enabled: refreshPlan,
})
- const features = getOrganizationSettingsFeatures(
- isEnterprise(billing?.data?.subscriptionPlan),
- deployment
- )
+ const accessDenied =
+ refreshPlan && isApiClientError(error) && [401, 403, 404].includes(error.status)
+ const isAdmin = viewer.isAdmin && !accessDenied
+ /** Server features paint immediately; the shared summary keeps portal changes current on focus. */
+ const features = {
+ ...settingsFeatures,
+ hasEnterprisePlan:
+ refreshPlan && summary
+ ? summary.data.subscriptionState === 'active' &&
+ isEnterprise(summary.data.subscriptionPlan) &&
+ hasUsableSubscriptionAccess(summary.data.subscriptionStatus, summary.data.billingBlocked)
+ : settingsFeatures.hasEnterprisePlan,
+ }
const routes = organizationRoutes(organization.id)
@@ -42,11 +52,18 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr
plane='organization'
activeSection={resolveOrganizationSurfaceSection(pathname ?? '')?.section ?? 'general'}
groups={ORGANIZATION_SETTINGS_GROUPS}
- items={organizationSurfaceSettingsNavigation(viewer.isAdmin, features, {
+ items={organizationSurfaceSettingsNavigation(isAdmin, features, {
connectedAccounts: connectedAccountsAvailable,
search: searchAccess.memberScoped,
})}
hrefForSection={(section) => routes.settingsSection(section)}
+ onSectionIntent={(section) =>
+ warmOrganizationSettingsSectionQuery(
+ queryClient,
+ { organizationId: organization.id, isAdmin },
+ section
+ )
+ }
backHref={searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH}
/>
)
diff --git a/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.test.ts b/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.test.ts
new file mode 100644
index 00000000000..4986fdf7fcd
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment node
+ */
+import { QueryClient } from '@tanstack/react-query'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson, mockGetOrganization } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+ mockGetOrganization: vi.fn(),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
+vi.mock('@/lib/auth/auth-client', () => ({
+ client: { organization: { getFullOrganization: mockGetOrganization } },
+}))
+
+import { warmOrganizationSettingsSectionQuery } from '@/app/o/[organizationId]/settings/settings-query-warmers'
+import {
+ organizationBillingQueryOptions,
+ organizationDetailQueryOptions,
+ organizationRosterQueryOptions,
+} from '@/hooks/queries/organization'
+import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
+
+let queryClient: QueryClient
+const adminContext = { organizationId: 'org-1', isAdmin: true }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ mockGetOrganization.mockResolvedValue({ data: { id: 'org-1' } })
+ mockRequestJson.mockResolvedValue({ success: true, data: { organizationId: 'org-1' } })
+})
+
+afterEach(() => queryClient.clear())
+
+describe('organization settings query warming', () => {
+ it('starts member data together and reuses it through the consumer options', async () => {
+ warmOrganizationSettingsSectionQuery(queryClient, adminContext, 'members')
+ expect(mockGetOrganization).toHaveBeenCalledExactlyOnceWith({
+ query: { organizationId: 'org-1' },
+ fetchOptions: { signal: expect.any(AbortSignal) },
+ })
+ expect(mockRequestJson).toHaveBeenCalledTimes(2)
+
+ await Promise.all([
+ queryClient.fetchQuery(organizationDetailQueryOptions('org-1')),
+ queryClient.fetchQuery(organizationRosterQueryOptions('org-1')),
+ queryClient.fetchQuery(organizationBillingQueryOptions('org-1')),
+ ])
+ warmOrganizationSettingsSectionQuery(queryClient, adminContext, 'members')
+
+ expect(mockGetOrganization).toHaveBeenCalledTimes(1)
+ expect(mockRequestJson).toHaveBeenCalledTimes(2)
+ expect(mockRequestJson.mock.calls.map(([, input]) => input)).toEqual([
+ { params: { id: 'org-1' }, signal: expect.any(AbortSignal) },
+ { query: { context: 'organization', id: 'org-1' }, signal: expect.any(AbortSignal) },
+ ])
+ })
+
+ it('does not request billing for ordinary members', async () => {
+ const memberContext = { ...adminContext, isAdmin: false }
+ warmOrganizationSettingsSectionQuery(queryClient, memberContext, 'members')
+ warmOrganizationSettingsSectionQuery(queryClient, memberContext, 'billing')
+ await queryClient.fetchQuery(organizationRosterQueryOptions('org-1'))
+
+ expect(mockRequestJson).toHaveBeenCalledTimes(1)
+ expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/organizations/[id]/roster')
+ })
+
+ it('warms Subscription using only its summary and keeps organizations separate', async () => {
+ warmOrganizationSettingsSectionQuery(queryClient, adminContext, 'billing')
+ warmOrganizationSettingsSectionQuery(
+ queryClient,
+ { ...adminContext, organizationId: 'org-2' },
+ 'billing'
+ )
+ await Promise.all([
+ queryClient.fetchQuery(organizationBillingSummaryOptions('org-1')),
+ queryClient.fetchQuery(organizationBillingSummaryOptions('org-2')),
+ ])
+
+ expect(mockGetOrganization).not.toHaveBeenCalled()
+ expect(mockRequestJson).toHaveBeenCalledTimes(2)
+ expect(mockRequestJson.mock.calls.map(([contract]) => contract.path)).toEqual([
+ '/api/organizations/[id]/billing-summary',
+ '/api/organizations/[id]/billing-summary',
+ ])
+ expect(mockRequestJson.mock.calls.map(([, input]) => input.params.id)).toEqual([
+ 'org-1',
+ 'org-2',
+ ])
+ })
+
+ it('does not fetch unrelated sections or an empty organization', () => {
+ warmOrganizationSettingsSectionQuery(queryClient, adminContext, 'general')
+ warmOrganizationSettingsSectionQuery(queryClient, adminContext, 'audit-logs')
+ warmOrganizationSettingsSectionQuery(
+ queryClient,
+ { ...adminContext, organizationId: '' },
+ 'members'
+ )
+
+ expect(mockGetOrganization).not.toHaveBeenCalled()
+ expect(mockRequestJson).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts b/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts
new file mode 100644
index 00000000000..bcec5b288a0
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts
@@ -0,0 +1,37 @@
+import type { QueryClient } from '@tanstack/react-query'
+import type {
+ AccountSettingsSection,
+ OrganizationSettingsSection,
+} from '@/components/settings/navigation'
+import {
+ organizationBillingQueryOptions,
+ organizationDetailQueryOptions,
+ organizationRosterQueryOptions,
+} from '@/hooks/queries/organization'
+import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
+import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent'
+
+interface OrganizationSettingsQueryWarmContext {
+ organizationId: string
+ isAdmin: boolean
+}
+
+/** Warms the selected panel's existing cache entries without mounting hidden query observers. */
+export function warmOrganizationSettingsSectionQuery(
+ queryClient: QueryClient,
+ { organizationId, isAdmin }: OrganizationSettingsQueryWarmContext,
+ section: AccountSettingsSection | OrganizationSettingsSection
+): void {
+ if (!organizationId) return
+
+ if (section === 'members') {
+ prefetchQueryOnIntent(queryClient, organizationDetailQueryOptions(organizationId))
+ prefetchQueryOnIntent(queryClient, organizationRosterQueryOptions(organizationId))
+ if (isAdmin) {
+ prefetchQueryOnIntent(queryClient, organizationBillingQueryOptions(organizationId))
+ }
+ }
+ if (section === 'billing' && isAdmin) {
+ prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(organizationId))
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
index 03dda46288f..9b4719d1d13 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
@@ -15,6 +15,13 @@ const desktopMocks = vi.hoisted(() => ({
unsubscribe: vi.fn(),
}))
+const { hostContext } = vi.hoisted(() => ({
+ hostContext: {
+ hostOrganizationId: null as string | null,
+ viewer: { isHostOrganizationMember: false },
+ },
+}))
+
vi.mock('@/lib/desktop', () => ({
getDesktopUpdates: () => ({
getState: desktopMocks.getState,
@@ -40,7 +47,7 @@ vi.mock('@/hooks/use-workspace-invite-policy', () => ({
useWorkspaceInvitePolicy: () => ({ isInvitationsDisabled: false }),
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
- useWorkspaceHostContext: () => null,
+ useWorkspaceHostContext: () => hostContext,
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip',
@@ -118,6 +125,8 @@ function menuItem(label: string): HTMLElement {
beforeEach(() => {
vi.clearAllMocks()
+ hostContext.hostOrganizationId = null
+ hostContext.viewer.isHostOrganizationMember = false
desktopMocks.listener = null
desktopMocks.onState.mockImplementation((listener) => {
desktopMocks.listener = listener
@@ -135,6 +144,25 @@ afterEach(() => {
})
describe('SidebarFooter', () => {
+ it('links members back to the organization hosting the current workspace', async () => {
+ hostContext.hostOrganizationId = 'host-org'
+ hostContext.viewer.isHostOrganizationMember = true
+ await renderFooter({ status: 'idle' })
+
+ openProfileMenu()
+
+ expect(menuItem('Organization')).toHaveAttribute('href', '/o/host-org')
+ })
+
+ it.each([null, 'host-org'])('hides Organization without host membership (%s)', async (orgId) => {
+ hostContext.hostOrganizationId = orgId
+ await renderFooter({ status: 'idle' })
+
+ openProfileMenu()
+
+ expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Organization')
+ })
+
it('keeps the overflow tooltip disabled while the collapsed tooltip still owns the trigger', async () => {
await renderFooter({ status: 'idle' }, { isCollapsed: false, showCollapsedTooltips: true })
const label = profileTrigger().querySelector('[data-overflow-text]')
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
index 44b135b4c92..d012e7f4cd3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
@@ -17,13 +17,23 @@ import {
OverflowText,
Skeleton,
} from '@sim/emcn'
-import { BookOpen, Credit, Download, HelpCircle, Settings, Trash, Users } from '@sim/emcn/icons'
+import {
+ BookOpen,
+ Building,
+ Credit,
+ Download,
+ HelpCircle,
+ Settings,
+ Trash,
+ Users,
+} from '@sim/emcn/icons'
import { SlackIcon } from '@/components/icons'
import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
import { useSession } from '@/lib/auth/auth-client'
import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { getDesktopUpdates } from '@/lib/desktop'
+import { organizationRoutes } from '@/lib/navigation/paths'
import { getUserColor } from '@/lib/workspaces/colors'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
@@ -146,6 +156,10 @@ export function SidebarFooter({
const name = profile ? profile.name?.trim() || profile.email : ''
const updateAvailable = hasAvailableDesktopUpdate(updateState)
+ const organizationHref =
+ hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationMember
+ ? organizationRoutes(hostContext.hostOrganizationId).root
+ : null
const handleUpdateSelect = () => {
const updates = getDesktopUpdates()
@@ -257,6 +271,17 @@ export function SidebarFooter({
+ {organizationHref && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
{menuItems.map(({ section, label, icon: Icon }) => {
const destination = resolveMenuDestination(section)
if (!destination) {
diff --git a/apps/sim/components/settings/settings-sidebar.test.tsx b/apps/sim/components/settings/settings-sidebar.test.tsx
index 97598ba687b..39076f884d0 100644
--- a/apps/sim/components/settings/settings-sidebar.test.tsx
+++ b/apps/sim/components/settings/settings-sidebar.test.tsx
@@ -6,10 +6,11 @@ import { Users } from '@sim/emcn/icons'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockPush, mockReplace, mockNavigate } = vi.hoisted(() => ({
+const { mockPush, mockReplace, mockNavigate, mockSectionIntent } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockReplace: vi.fn(),
mockNavigate: vi.fn(),
+ mockSectionIntent: vi.fn(),
}))
vi.mock('next/navigation', () => ({
@@ -20,6 +21,7 @@ vi.mock('@/app/(landing)/components/navbar/components', () => ({ SimWordmark: ()
vi.mock('@/components/settings/settings-intent-link', () => ({
SettingsIntentLink: ({
onNavigate,
+ onIntent,
replace: _replace,
scroll: _scroll,
...props
@@ -27,10 +29,12 @@ vi.mock('@/components/settings/settings-intent-link', () => ({
replace?: boolean
scroll?: boolean
onNavigate?: (event: { preventDefault: () => void }) => void
+ onIntent?: () => void
}) => (
{
event.preventDefault()
let prevented = false
@@ -78,6 +82,7 @@ function renderSidebar(isCollapsed = false) {
{ id: 'search-mcp', label: 'Search MCP', group: 'organization', icon: Users },
]}
hrefForSection={(section) => `/o/org-a/settings/${section}`}
+ onSectionIntent={mockSectionIntent}
backHref='/o/org-a/home'
isCollapsed={isCollapsed}
/>
@@ -94,6 +99,18 @@ function button(label: string): HTMLButtonElement {
}
describe('SettingsSidebar interactions', () => {
+ it('warms only the destination section on intent, without navigating', () => {
+ renderSidebar()
+ expect(mockSectionIntent).not.toHaveBeenCalled()
+
+ act(() => container.querySelector('a[href$="/members"]')?.focus())
+ expect(mockSectionIntent).not.toHaveBeenCalled()
+
+ act(() => container.querySelector('a[href$="/search-mcp"]')?.focus())
+ expect(mockSectionIntent).toHaveBeenCalledExactlyOnceWith('search-mcp')
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
it('keeps destinations available in the icon rail after collapsing a section', () => {
renderSidebar()
act(() => button('Organization').click())
diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx
index 1be5ff22031..e1a8c5aec4d 100644
--- a/apps/sim/components/settings/settings-sidebar.tsx
+++ b/apps/sim/components/settings/settings-sidebar.tsx
@@ -68,6 +68,7 @@ interface SettingsSidebarProps {
plane: StandaloneSettingsPlane
groups: readonly SettingsNavigationGroup[]
hrefForSection: (section: Section) => string
+ onSectionIntent?: (section: Section) => void
items: readonly SidebarSettingsItem[]
outboundLinks?: readonly SettingsSidebarOutboundLink[]
isCollapsed?: boolean
@@ -80,6 +81,7 @@ export function SettingsSidebar({
plane,
groups,
hrefForSection,
+ onSectionIntent,
items,
outboundLinks = [],
isCollapsed = false,
@@ -178,6 +180,9 @@ export function SettingsSidebar({
replace
scroll={false}
aria-current={active ? 'page' : undefined}
+ onIntent={() => {
+ if (!active) onSectionIntent?.(item.id)
+ }}
className={cn(
chipVariants({ active, fullWidth: true }),
SIDEBAR_RAIL_CHIP_CLASS
diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts
index 7cd80f8b232..b2e40daaa43 100644
--- a/apps/sim/hooks/queries/organization.ts
+++ b/apps/sim/hooks/queries/organization.ts
@@ -1,6 +1,12 @@
import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
-import { type UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ queryOptions,
+ type UseQueryResult,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import type { ContractBodyInput } from '@/lib/api/contracts'
@@ -106,12 +112,18 @@ async function fetchOrganizationRoster(
}
}
+export function organizationRosterQueryOptions(orgId: string) {
+ return queryOptions({
+ queryKey: organizationKeys.roster(orgId),
+ queryFn: ({ signal }) => fetchOrganizationRoster(orgId, signal),
+ staleTime: ORGANIZATION_ROSTER_STALE_TIME,
+ })
+}
+
export function useOrganizationRoster(orgId: string | undefined | null) {
return useQuery({
- queryKey: organizationKeys.roster(orgId ?? ''),
- queryFn: ({ signal }) => fetchOrganizationRoster(orgId as string, signal),
+ ...organizationRosterQueryOptions(orgId ?? ''),
enabled: !!orgId,
- staleTime: ORGANIZATION_ROSTER_STALE_TIME,
})
}
@@ -166,18 +178,21 @@ async function fetchOrganization(orgId: string, signal?: AbortSignal) {
return response.data
}
-/**
- * Hook to fetch a specific organization
- */
-export function useOrganization(orgId: string) {
- return useQuery({
+export function organizationDetailQueryOptions(orgId: string) {
+ return queryOptions({
queryKey: organizationKeys.detail(orgId),
queryFn: ({ signal }) => fetchOrganization(orgId, signal),
- enabled: !!orgId,
staleTime: ORGANIZATION_DETAIL_STALE_TIME,
})
}
+export function useOrganization(orgId: string) {
+ return useQuery({
+ ...organizationDetailQueryOptions(orgId),
+ enabled: !!orgId,
+ })
+}
+
/**
* Fetch organization billing data
*/
@@ -198,19 +213,22 @@ async function fetchOrganizationBilling(
}
}
-/**
- * Hook to fetch organization billing data
- */
+export function organizationBillingQueryOptions(orgId: string) {
+ return queryOptions({
+ queryKey: organizationKeys.billing(orgId),
+ queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal),
+ retry: false,
+ staleTime: ORGANIZATION_BILLING_STALE_TIME,
+ })
+}
+
export function useOrganizationBilling(
orgId: string,
options?: { enabled?: boolean }
): OrganizationBillingQueryResult {
return useQuery({
- queryKey: organizationKeys.billing(orgId),
- queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal),
+ ...organizationBillingQueryOptions(orgId),
enabled: !!orgId && (options?.enabled ?? true),
- retry: false,
- staleTime: ORGANIZATION_BILLING_STALE_TIME,
})
}
diff --git a/apps/sim/lib/organizations/surface.test.ts b/apps/sim/lib/organizations/surface.test.ts
index e30cfbd2d0f..f3a06c0bd10 100644
--- a/apps/sim/lib/organizations/surface.test.ts
+++ b/apps/sim/lib/organizations/surface.test.ts
@@ -2,13 +2,13 @@
* @vitest-environment node
*/
import { member, organization } from '@sim/db/schema'
-import { queueTableRows, resetDbChainMock } from '@sim/testing'
+import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockSearchAccess, mockPermissionConfig, featureFlags } = vi.hoisted(() => ({
+const { mockSearchAccess, mockPermissionConfig, mockEnterprisePlan } = vi.hoisted(() => ({
mockSearchAccess: vi.fn(),
mockPermissionConfig: vi.fn(),
- featureFlags: { invitationsDisabled: false },
+ mockEnterprisePlan: vi.fn(),
}))
vi.mock('@/lib/credential-groups/scoped-availability', () => ({
isScopedCredentialGroupsAvailable: vi.fn().mockResolvedValue(true),
@@ -17,10 +17,8 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({
vi.mock('@/lib/permission-groups/resolve.server', () => ({
getUserPermissionConfigForOrganization: mockPermissionConfig,
}))
-vi.mock('@/lib/core/config/env-flags', () => ({
- get isInvitationsDisabled() {
- return featureFlags.invitationsDisabled
- },
+vi.mock('@/lib/billing/core/subscription', () => ({
+ isOrganizationOnEnterprisePlan: mockEnterprisePlan,
}))
vi.mock('@/lib/knowledge/access/availability', () => ({
resolveKnowledgeAccessAvailability: mockSearchAccess,
@@ -33,6 +31,7 @@ import {
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
afterAll(resetDbChainMock)
+afterAll(resetEnvFlagsMock)
describe('getOrganizationSurfaceContext', () => {
beforeEach(() => {
@@ -40,7 +39,8 @@ describe('getOrganizationSurfaceContext', () => {
resetDbChainMock()
mockSearchAccess.mockResolvedValue({ memberScoped: true, sourceMirrored: false })
mockPermissionConfig.mockResolvedValue(null)
- featureFlags.invitationsDisabled = false
+ mockEnterprisePlan.mockResolvedValue(true)
+ setEnvFlags({ isInvitationsDisabled: false, isHosted: true, isBillingEnabled: true })
})
it('returns the organization and the viewer standing for a member', async () => {
@@ -66,8 +66,14 @@ describe('getOrganizationSurfaceContext', () => {
},
connectedAccountsAvailable: true,
searchAccess: { memberScoped: true, sourceMirrored: false },
+ settingsFeatures: expect.objectContaining({
+ hosted: true,
+ billingEnabled: true,
+ hasEnterprisePlan: true,
+ }),
})
expect(mockSearchAccess).toHaveBeenCalledWith({ organizationId: 'org-1' })
+ expect(mockEnterprisePlan).toHaveBeenCalledWith('org-1')
})
it('normalizes a missing logo to null', async () => {
@@ -78,7 +84,25 @@ describe('getOrganizationSurfaceContext', () => {
await expect(getOrganizationSurfaceContext('org-1', 'viewer')).resolves.toMatchObject({
organization: { logo: null },
viewer: { role: 'member', isAdmin: false },
+ settingsFeatures: expect.objectContaining({ hasEnterprisePlan: false }),
})
+ expect(mockEnterprisePlan).not.toHaveBeenCalled()
+ })
+
+ it('resolves self-hosted settings from deployment flags without a plan read', async () => {
+ setEnvFlags({ isHosted: false, isBillingEnabled: false, isAuditLogsEnabled: true })
+ queueTableRows(member, [{ role: 'admin' }])
+ queueTableRows(organization, [{ id: 'org-1', name: 'Acme', slug: 'acme', logo: null }])
+ queueTableRows(member, [{ memberCount: 1 }])
+
+ await expect(getOrganizationSurfaceContext('org-1', 'viewer')).resolves.toMatchObject({
+ settingsFeatures: {
+ hosted: false,
+ billingEnabled: false,
+ selfHosted: { 'audit-logs': true },
+ },
+ })
+ expect(mockEnterprisePlan).not.toHaveBeenCalled()
})
it.each([
@@ -92,7 +116,7 @@ describe('getOrganizationSurfaceContext', () => {
queueTableRows(member, [{ role }])
queueTableRows(organization, [{ id: 'org-1', name: 'Acme', slug: 'acme', logo: null }])
queueTableRows(member, [{ memberCount: 1 }])
- featureFlags.invitationsDisabled = deploymentDisabled
+ setEnvFlags({ isInvitationsDisabled: deploymentDisabled })
mockPermissionConfig.mockResolvedValue({
...DEFAULT_PERMISSION_GROUP_CONFIG,
disableInvitations: policyDisabled,
@@ -110,6 +134,7 @@ describe('getOrganizationSurfaceContext', () => {
await expect(getOrganizationSurfaceContext('org-1', 'viewer')).resolves.toBeNull()
expect(mockSearchAccess).not.toHaveBeenCalled()
+ expect(mockEnterprisePlan).not.toHaveBeenCalled()
})
it('denies a membership whose organization row is gone', async () => {
diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts
index 6b5f95e8495..9ee55d306bb 100644
--- a/apps/sim/lib/organizations/surface.ts
+++ b/apps/sim/lib/organizations/surface.ts
@@ -2,7 +2,13 @@ import { cache } from 'react'
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { asc, count, eq } from 'drizzle-orm'
+import {
+ getOrganizationSettingsFeatures,
+ type OrganizationSettingsFeatures,
+} from '@/components/settings/navigation'
import type { OrganizationRole } from '@/lib/api/contracts/primitives'
+import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
+import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isInvitationsDisabled } from '@/lib/core/config/env-flags'
import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability'
import {
@@ -39,6 +45,7 @@ export interface OrganizationSurfaceContext {
viewer: OrganizationSurfaceViewer
connectedAccountsAvailable: boolean
searchAccess: KnowledgeAccessAvailability
+ settingsFeatures: OrganizationSettingsFeatures
}
/**
@@ -65,13 +72,20 @@ async function resolveOrganizationSurfaceContext(
.limit(1)
if (!row) return null
- const [config, [{ memberCount }]] = await Promise.all([
- getUserPermissionConfigForOrganization(organizationId),
- db
- .select({ memberCount: count() })
- .from(member)
- .where(eq(member.organizationId, organizationId)),
- ])
+ const deployment = getDeploymentShape()
+ const [config, [{ memberCount }], connectedAccountsAvailable, searchAccess, hasEnterprisePlan] =
+ await Promise.all([
+ getUserPermissionConfigForOrganization(organizationId),
+ db
+ .select({ memberCount: count() })
+ .from(member)
+ .where(eq(member.organizationId, organizationId)),
+ isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }),
+ resolveKnowledgeAccessAvailability({ organizationId }),
+ deployment.hosted && access.isAdmin
+ ? isOrganizationOnEnterprisePlan(organizationId)
+ : Promise.resolve(false),
+ ])
return {
organization: {
id: row.id,
@@ -89,11 +103,9 @@ async function resolveOrganizationSurfaceContext(
!capabilityDeniedBy('personal_api_key.use', config) &&
!capabilityDeniedBy('api_keys.manage', config),
},
- connectedAccountsAvailable: await isScopedCredentialGroupsAvailable({
- kind: 'organization',
- organizationId,
- }),
- searchAccess: await resolveKnowledgeAccessAvailability({ organizationId }),
+ connectedAccountsAvailable,
+ searchAccess,
+ settingsFeatures: getOrganizationSettingsFeatures(hasEnterprisePlan, deployment),
}
}