fix(upgrade): client side env var check (#5673)

* fix(upgrade): client side env var check

* fix misc self hosted visibility

* add tests
This commit is contained in:
Vikhyath Mondreti
2026-07-14 08:35:33 -07:00
committed by GitHub
parent 3282fd8a60
commit db2fb3cc61
7 changed files with 83 additions and 11 deletions
@@ -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()
@@ -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
+12 -1
View File
@@ -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
@@ -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')
@@ -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 {
@@ -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
})
+31 -8
View File
@@ -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'))
/**
* 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