diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 71f10cbea0e..29e0ebb241f 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -10,7 +10,7 @@ import { parseSettingsPathSection, } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { isPlatformAdmin } from '@/lib/permissions/super-user' interface AccountSettingsSectionPageProps { @@ -46,6 +46,7 @@ export default async function AccountSettingsSectionPage({ }) if (!parsed) notFound() if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general')) + if (parsed === 'copilot' && !isHosted) redirect(getAccountSettingsHref('general')) if (parsed === 'admin' || parsed === 'mothership') { const isSuperUser = await isPlatformAdmin(session.user.id) if (!isSuperUser) notFound() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 50cd965a646..3519b23a036 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' +import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' @@ -120,6 +121,15 @@ export function SettingsSidebar({ } if (item.selfHostedOverride && !isHosted) { + /** + * Org-plane sections route through the organization gate in + * `settings/[section]/page.tsx` (host organization + org-admin viewer), + * which 404s other viewers — mirror it here so the item never links to + * a dead page. + */ + if (ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && !isOrgAdminOrOwner) { + return false + } if (item.id === 'sso') { const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0 return !hasProviders || isSSOProviderOwner === true diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index a803bdf8c26..f70ab7b06ee 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -159,6 +159,17 @@ function getProviderFromStore(model: string): string | null { return null } +/** + * Whether an Ollama instance is available. `isOllamaConfigured` reads the + * server-only `OLLAMA_URL` env var, which is always undefined in the browser — + * there the providers store (populated from the server's model list, which is + * non-empty only when Ollama is configured) is the signal. + */ +function isOllamaAvailable(): boolean { + if (isOllamaConfigured) return true + return useProvidersStore.getState().providers.ollama.models.length > 0 +} + function buildModelVisibilityCondition(model: string, shouldShow: boolean) { if (!model) { return { field: 'model', value: '__no_model_selected__' } @@ -197,7 +208,7 @@ function shouldRequireApiKeyForModel(model: string): boolean { return false if (storeProvider) return true - if (isOllamaConfigured) { + if (isOllamaAvailable()) { if (normalizedModel.includes('/')) return true if (normalizedModel in getBaseModelProviders()) return true return false diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 69d2b9d5029..2348ced7a97 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -11,6 +11,7 @@ import { getOrganizationSettingsHref, getWorkspaceSettingsHref, isOrganizationSettingsSectionAvailable, + ORGANIZATION_PLANE_UNIFIED_SECTIONS, ORGANIZATION_SETTINGS_ITEMS, ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, @@ -109,6 +110,19 @@ describe('settings navigation boundaries', () => { expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort()) }) + it('derives the organization-plane unified sections from the registry', () => { + expect([...ORGANIZATION_PLANE_UNIFIED_SECTIONS].sort()).toEqual([ + 'access-control', + 'audit-logs', + 'billing', + 'data-drains', + 'data-retention', + 'organization', + 'sso', + 'whitelabeling', + ]) + }) + it('shares labels, icons, and docs links across projections', () => { const unifiedSso = buildUnifiedSettingsNavigation().find(({ id }) => id === 'sso') const organizationSso = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'sso') diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 2c3446e9203..c03e332de9e 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -671,6 +671,18 @@ export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('workspace') +/** + * Unified sections that resolve to organization-plane settings. The workspace + * settings section page routes these through the organization gate (host + * organization present + org-admin viewer), so workspace-plane navigation must + * apply the same requirement before surfacing them. + */ +export const ORGANIZATION_PLANE_UNIFIED_SECTIONS: ReadonlySet = new Set( + SETTINGS_SECTION_REGISTRY.flatMap((entry) => + entry.planes?.organization ? [entry.unified.id] : [] + ) +) + export type OrganizationSectionAccess = 'unavailable' | 'view' | 'manage' interface ResolveOrganizationSectionAccessOptions { diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 70eead4cb3e..43911876480 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -21,7 +21,7 @@ import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settin import { SettingsSectionProvider } from '@/components/settings/settings-panel' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' interface StandaloneSettingsShellBaseProps { children: ReactNode @@ -52,6 +52,7 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan) const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { if (item.id === 'billing' && !isBillingEnabled) return false + if (item.id === 'copilot' && !isHosted) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false return true }) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 64a0d7cdb33..2df2aeae735 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -46,9 +46,18 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) /** - * Is billing enforcement enabled + * Is billing enforcement enabled. + * + * Server code reads `BILLING_ENABLED`. Server-only vars never reach browser + * bundles, so client evaluation reads the `NEXT_PUBLIC_BILLING_ENABLED` twin + * (via `window.__ENV`, populated by ``) — reading + * `env.BILLING_ENABLED` in client code is always `undefined`. Deployments must + * set both vars together. */ -export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) +export const isBillingEnabled = + typeof window === 'undefined' + ? isTruthy(env.BILLING_ENABLED) + : isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) /** * Block free-plan accounts from programmatic workflow execution (API key, public @@ -154,18 +163,32 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) export const isSsoEnabled = isTruthy(env.SSO_ENABLED) /** - * Is access control (permission groups) enabled via env var override - * This bypasses plan requirements for self-hosted deployments + * Is access control (permission groups) enabled via env var override. + * This bypasses plan requirements for self-hosted deployments. + * + * Server code reads `ACCESS_CONTROL_ENABLED`; the browser reads the + * `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` twin (see {@link isBillingEnabled}). */ -export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED) +export const isAccessControlEnabled = + typeof window === 'undefined' + ? isTruthy(env.ACCESS_CONTROL_ENABLED) + : isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')) /** - * Is organizations enabled + * Is organizations enabled. * True if billing is enabled (orgs come with billing), OR explicitly enabled via env var, - * OR if access control is enabled (access control requires organizations) + * OR if access control is enabled (access control requires organizations). + * + * Each term resolves through its `NEXT_PUBLIC_*` twin in the browser (see + * {@link isBillingEnabled}), so client code — e.g. the better-auth + * `organizationClient` plugin registration — sees the same value as the server. */ export const isOrganizationsEnabled = - isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled + isBillingEnabled || + (typeof window === 'undefined' + ? isTruthy(env.ORGANIZATIONS_ENABLED) + : isTruthy(getEnv('NEXT_PUBLIC_ORGANIZATIONS_ENABLED'))) || + isAccessControlEnabled /** * Is inbox (Sim Mailer) enabled via env var override