From e0f049b3cee78ca05042b3f5fc51803ee52d0404 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 13:09:29 -0700 Subject: [PATCH] fix(organization): improve return navigation and settings loading --- .../app/o/[organizationId]/home/page.test.tsx | 17 +- .../app/o/[organizationId]/layout.test.tsx | 16 +- apps/sim/app/o/[organizationId]/layout.tsx | 2 +- apps/sim/app/o/[organizationId]/page.tsx | 11 +- .../settings/[section]/layout.test.tsx | 39 ++++ .../settings/[section]/layout.tsx | 12 +- .../settings/[section]/loading.tsx | 4 + .../settings/[section]/page.tsx | 3 - .../settings/[section]/settings.tsx | 19 +- .../organization-settings-sidebar.test.tsx | 197 ++++++++++++++++++ .../organization-settings-sidebar.tsx | 47 +++-- .../settings/settings-query-warmers.test.ts | 107 ++++++++++ .../settings/settings-query-warmers.ts | 37 ++++ .../sidebar-footer/sidebar-footer.test.tsx | 30 ++- .../sidebar-footer/sidebar-footer.tsx | 27 ++- .../settings/settings-sidebar.test.tsx | 19 +- .../components/settings/settings-sidebar.tsx | 5 + apps/sim/hooks/queries/organization.ts | 52 +++-- apps/sim/lib/organizations/surface.test.ts | 43 +++- apps/sim/lib/organizations/surface.ts | 36 ++-- 20 files changed, 651 insertions(+), 72 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx create mode 100644 apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx create mode 100644 apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.test.tsx create mode 100644 apps/sim/app/o/[organizationId]/settings/settings-query-warmers.test.ts create mode 100644 apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts 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), } }