mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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()
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user