Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__' }
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getOrganizationSettingsHref,
getWorkspaceSettingsHref,
isOrganizationSettingsSectionAvailable,
ORGANIZATION_PLANE_UNIFIED_SECTIONS,
ORGANIZATION_SETTINGS_ITEMS,
ORGANIZATION_SETTINGS_PATH_ALIASES,
parseSettingsPathSection,
Expand Down Expand Up @@ -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')
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/components/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,18 @@ export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem<OrganizationSet
export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem<WorkspaceSettingsSection>[] =
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<UnifiedSettingsSection> = new Set(
SETTINGS_SECTION_REGISTRY.flatMap((entry) =>
entry.planes?.organization ? [entry.unified.id] : []
)
)

export type OrganizationSectionAccess = 'unavailable' | 'view' | 'manage'

interface ResolveOrganizationSectionAccessOptions {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/components/settings/standalone-settings-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
})
Expand Down
39 changes: 31 additions & 8 deletions apps/sim/lib/core/config/env-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<PublicEnvScript>`) — 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'))
Comment thread
icecrasher321 marked this conversation as resolved.

/**
* Block free-plan accounts from programmatic workflow execution (API key, public
Expand Down Expand Up @@ -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
Expand Down
Loading