From 7ede95831f45b71be218880b9637dfc31eea5166 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 12:05:40 -0700 Subject: [PATCH 1/6] fix(auth): skip email verification when no mail provider is configured Signup pushed /verify unconditionally, stranding self-hosted deployments with no mail provider on a screen no email could ever satisfy. Derive one server-side effective value (verification enabled AND deliverable) and read it from Better Auth enforcement, signup routing, and the verify page. --- apps/sim/app/(auth)/auth-redirect.test.ts | 59 +++++++++++++++++++ apps/sim/app/(auth)/auth-redirect.ts | 38 ++++++++++++ apps/sim/app/(auth)/signup/page.tsx | 2 + apps/sim/app/(auth)/signup/signup-form.tsx | 40 ++++++++++--- apps/sim/app/(auth)/verify/page.test.tsx | 53 +++++++++++++++++ apps/sim/app/(auth)/verify/page.tsx | 5 +- apps/sim/lib/auth/auth.ts | 5 +- .../lib/messaging/email/verification.test.ts | 45 ++++++++++++++ apps/sim/lib/messaging/email/verification.ts | 15 +++++ 9 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 apps/sim/app/(auth)/auth-redirect.test.ts create mode 100644 apps/sim/app/(auth)/verify/page.test.tsx create mode 100644 apps/sim/lib/messaging/email/verification.test.ts create mode 100644 apps/sim/lib/messaging/email/verification.ts diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts new file mode 100644 index 00000000000..e4b9e25b3df --- /dev/null +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect' + +describe('resolvePostSignupDestination', () => { + it('routes to the verify hop when verification is enforceable', () => { + expect( + resolvePostSignupDestination({ emailVerificationEnabled: true, redirectUrl: '' }) + ).toEqual({ kind: 'verify' }) + }) + + it('keeps the verify hop owning the callback URL when verification is enforceable', () => { + expect( + resolvePostSignupDestination({ + emailVerificationEnabled: true, + redirectUrl: '/invite/abc', + }) + ).toEqual({ kind: 'verify' }) + }) + + /** + * Regression guard: signup used to push `/verify` unconditionally, stranding + * self-hosted deployments with no mail provider on a screen no email can + * satisfy. + */ + it('never routes to verify when no mail provider is configured', () => { + expect( + resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' }) + ).toEqual({ kind: 'workspace' }) + }) + + it('preserves the callback URL when verification is not enforceable', () => { + expect( + resolvePostSignupDestination({ + emailVerificationEnabled: false, + redirectUrl: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz', + }) + ).toEqual({ + kind: 'redirect', + url: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz', + }) + }) +}) + +describe('buildAuthCrossLink', () => { + it('carries the invite flow and callback URL across the login/signup hop', () => { + expect(buildAuthCrossLink('/login', { callbackUrl: '/invite/abc', isInviteFlow: true })).toBe( + '/login?invite_flow=true&callbackUrl=%2Finvite%2Fabc' + ) + }) + + it('drops the query entirely when nothing needs carrying', () => { + expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: false })).toBe( + '/signup' + ) + }) +}) diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 75657ebb57e..0cfd1310b22 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -5,6 +5,44 @@ */ export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl' +/** Route the verify hop lives at, entered only from signup. */ +export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true' + +/** Default post-auth destination when no callback URL was carried in. */ +export const DEFAULT_POST_AUTH_ROUTE = '/workspace' + +/** + * Where a successful email signup goes next. + * - `verify`: the verification hop, which owns the post-auth redirect from there + * - `redirect`: the validated callback URL the visitor arrived with + * - `workspace`: the default destination + */ +export type PostSignupDestination = + | { kind: 'verify' } + | { kind: 'redirect'; url: string } + | { kind: 'workspace' } + +interface PostSignupDestinationParams { + /** The server-derived effective flag — verification enabled AND deliverable. */ + emailVerificationEnabled: boolean + /** Callback URL that already passed `validateCallbackUrl`, or `''`. */ + redirectUrl: string +} + +/** + * `/verify` is a destination only when the deployment can actually deliver the + * code. A deployment with no mail provider would otherwise strand every new + * account on a screen no email can ever satisfy, so signup continues straight + * to the normal post-auth destination instead. + */ +export function resolvePostSignupDestination({ + emailVerificationEnabled, + redirectUrl, +}: PostSignupDestinationParams): PostSignupDestination { + if (emailVerificationEnabled) return { kind: 'verify' } + return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } +} + interface AuthCrossLinkParams { /** Validated post-auth destination to carry over, or null to drop it. */ callbackUrl: string | null diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 1fbd4cbfd22..3d5a8933cd6 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' @@ -24,6 +25,7 @@ export default async function SignupPage() { microsoftAvailable={microsoftAvailable} isProduction={isProduction} emailSignupEnabled={!isEmailSignupDisabled} + emailVerificationEnabled={isEmailVerificationEffectivelyEnabled()} /> ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index de337cc5c3e..4915da788b3 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -11,7 +11,13 @@ import { isSsoEnabled } from '@/lib/core/config/env-flags' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent, captureEvent } from '@/lib/posthog/client' -import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' +import { + buildAuthCrossLink, + DEFAULT_POST_AUTH_ROUTE, + POST_AUTH_REDIRECT_STORAGE_KEY, + resolvePostSignupDestination, + VERIFY_FROM_SIGNUP_ROUTE, +} from '@/app/(auth)/auth-redirect' import { AuthDivider, AuthField, @@ -86,6 +92,8 @@ interface SignupFormProps { microsoftAvailable: boolean isProduction: boolean emailSignupEnabled: boolean + /** Server-derived: verification is enabled AND a mail provider is configured. */ + emailVerificationEnabled: boolean } function SignupFormContent({ @@ -94,6 +102,7 @@ function SignupFormContent({ microsoftAvailable, isProduction, emailSignupEnabled, + emailVerificationEnabled, }: SignupFormProps) { const router = useRouter() const searchParams = useSearchParams() @@ -343,18 +352,29 @@ function SignupFormContent({ logger.error('Failed to refresh session after signup:', sessionError) } + const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl }) + if (typeof window !== 'undefined') { - sessionStorage.setItem('verificationEmail', emailValue) - if (redirectUrl) { - sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) - } else { - // Clear any leftover from an earlier signup in this tab — otherwise a - // signup with no callbackUrl inherits the previous CLI/invite destination. - sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) + // Clear any leftover from an earlier signup in this tab — otherwise a + // signup with no callbackUrl inherits the previous CLI/invite destination. + sessionStorage.removeItem('verificationEmail') + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) + + if (destination.kind === 'verify') { + sessionStorage.setItem('verificationEmail', emailValue) + if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) } } - router.push('/verify?fromSignup=true') + if (destination.kind === 'verify') { + router.push(VERIFY_FROM_SIGNUP_ROUTE) + } else if (destination.kind === 'redirect') { + // Full navigation, matching the verify hop: the destination (invite, CLI + // handoff) is server-rendered and must see the fresh session cookie. + window.location.href = destination.url + } else { + router.push(DEFAULT_POST_AUTH_ROUTE) + } } catch (error) { logger.error('Signup error:', error) setIsLoading(false) @@ -488,6 +508,7 @@ export default function SignupPage({ microsoftAvailable, isProduction, emailSignupEnabled, + emailVerificationEnabled, }: SignupFormProps) { return ( ) diff --git a/apps/sim/app/(auth)/verify/page.test.tsx b/apps/sim/app/(auth)/verify/page.test.tsx new file mode 100644 index 00000000000..1de1f85305c --- /dev/null +++ b/apps/sim/app/(auth)/verify/page.test.tsx @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasEmailService, mockIsEmailVerificationEffectivelyEnabled } = vi.hoisted(() => ({ + mockHasEmailService: vi.fn<() => boolean>(), + mockIsEmailVerificationEffectivelyEnabled: vi.fn<() => boolean>(), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ + hasEmailService: mockHasEmailService, +})) + +vi.mock('@/lib/messaging/email/verification', () => ({ + isEmailVerificationEffectivelyEnabled: mockIsEmailVerificationEffectivelyEnabled, +})) + +vi.mock('@/app/(auth)/verify/verify-content', () => ({ + VerifyContent: () => null, +})) + +import VerifyPage from '@/app/(auth)/verify/page' + +describe('verify page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the verification experience when a mail provider is configured', () => { + mockHasEmailService.mockReturnValue(true) + mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(true) + + const element = VerifyPage() + + expect(element.props.hasEmailService).toBe(true) + expect(element.props.isEmailVerificationEnabled).toBe(true) + }) + + /** + * The page hands the effective value down, so the verification form never + * renders on a deployment that cannot deliver a code — it redirects instead. + */ + it('reports verification off when no mail provider is configured', () => { + mockHasEmailService.mockReturnValue(false) + mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(false) + + const element = VerifyPage() + + expect(element.props.hasEmailService).toBe(false) + expect(element.props.isEmailVerificationEnabled).toBe(false) + }) +}) diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx index c8825186d02..0f828b78b4f 100644 --- a/apps/sim/app/(auth)/verify/page.tsx +++ b/apps/sim/app/(auth)/verify/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next' -import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags' +import { isProd } from '@/lib/core/config/env-flags' import { hasEmailService } from '@/lib/messaging/email/mailer' +import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { VerifyContent } from '@/app/(auth)/verify/verify-content' export const metadata: Metadata = { @@ -16,7 +17,7 @@ export default function VerifyPage() { ) } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 87233c9fc40..ed190e0e782 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -90,6 +90,7 @@ import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' +import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' @@ -792,7 +793,7 @@ export const auth = betterAuth({ * can still sign in. */ disableSignUp: isEmailSignupDisabled, - requireEmailVerification: isEmailVerificationEnabled, + requireEmailVerification: isEmailVerificationEffectivelyEnabled(), /** * When someone signs up with an already-registered email, better-auth returns a * generic success response (OWASP enumeration protection) instead of leaking that @@ -1459,7 +1460,7 @@ export const auth = betterAuth({ organization({ allowUserToCreateOrganization: async () => false, disableOrganizationDeletion: true, - requireEmailVerificationOnInvitation: isEmailVerificationEnabled, + requireEmailVerificationOnInvitation: isEmailVerificationEffectivelyEnabled(), organizationHooks: { afterCreateOrganization: async ({ organization, user }) => { logger.info('[organizationHooks.afterCreateOrganization] Organization created', { diff --git a/apps/sim/lib/messaging/email/verification.test.ts b/apps/sim/lib/messaging/email/verification.test.ts new file mode 100644 index 00000000000..a3b1a8fc6f0 --- /dev/null +++ b/apps/sim/lib/messaging/email/verification.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasEmailService } = vi.hoisted(() => ({ + mockHasEmailService: vi.fn<() => boolean>(), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ + hasEmailService: mockHasEmailService, +})) + +import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' + +describe('isEmailVerificationEffectivelyEnabled', () => { + beforeEach(() => { + vi.clearAllMocks() + resetEnvFlagsMock() + }) + + afterAll(resetEnvFlagsMock) + + it('requires verification when it is enabled and a mail provider is configured', () => { + setEnvFlags({ isEmailVerificationEnabled: true }) + mockHasEmailService.mockReturnValue(true) + + expect(isEmailVerificationEffectivelyEnabled()).toBe(true) + }) + + it('does not require verification when no mail provider is configured', () => { + setEnvFlags({ isEmailVerificationEnabled: true }) + mockHasEmailService.mockReturnValue(false) + + expect(isEmailVerificationEffectivelyEnabled()).toBe(false) + }) + + it('does not require verification when the feature is disabled', () => { + setEnvFlags({ isEmailVerificationEnabled: false }) + mockHasEmailService.mockReturnValue(true) + + expect(isEmailVerificationEffectivelyEnabled()).toBe(false) + }) +}) diff --git a/apps/sim/lib/messaging/email/verification.ts b/apps/sim/lib/messaging/email/verification.ts new file mode 100644 index 00000000000..52736bab714 --- /dev/null +++ b/apps/sim/lib/messaging/email/verification.ts @@ -0,0 +1,15 @@ +import { isEmailVerificationEnabled } from '@/lib/core/config/env-flags' +import { hasEmailService } from '@/lib/messaging/email/mailer' + +/** + * Whether email verification is actually enforceable on this deployment. + * + * `EMAIL_VERIFICATION_ENABLED` alone only says the operator wants verification; + * without a configured mail provider no code can ever be delivered, so + * enforcing it locks every new account out behind a screen it cannot satisfy. + * This is the single server-derived value Better Auth enforcement, signup + * routing, and the verify page all read, so they cannot disagree. + */ +export function isEmailVerificationEffectivelyEnabled(): boolean { + return isEmailVerificationEnabled && hasEmailService() +} From 4d115b31a3be7d519b0e02cb6017b5cd1bc28068 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 12:05:54 -0700 Subject: [PATCH 2/6] feat(settings): add a self-host section with the managed Chat keys link Self-hosters had no in-app pointer to the managed service that issues their Chat keys. New Settings > System > Self-host section, gated on `requiresSelfHosted` so it is absent on hosted Sim, containing only that link. --- .../[workspaceId]/settings/[section]/page.tsx | 1 + .../settings/[section]/settings.tsx | 6 ++ .../components/self-host/self-host.test.tsx | 26 +++++++++ .../components/self-host/self-host.tsx | 29 ++++++++++ .../[workspaceId]/settings/navigation.test.ts | 2 + .../components/settings/navigation.test.ts | 57 ++++++++++++++++++- apps/sim/components/settings/navigation.ts | 28 +++++++++ 7 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index fee9364735d..2f84f225401 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -58,6 +58,7 @@ const WORKSPACE_SECTION_MAP: Partial> = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 9409ccc55e2..632e818dec1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -55,6 +55,11 @@ const RecentlyDeleted = dynamic(() => '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' ).then((m) => m.RecentlyDeleted) ) +const SelfHost = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host').then( + (m) => m.SelfHost + ) +) const Billing = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing) ) @@ -200,6 +205,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'workflow-mcp-servers' && } {effectiveSection === 'inbox' && } {effectiveSection === 'recently-deleted' && } + {effectiveSection === 'self-host' && } {effectiveSection === 'admin' && } {effectiveSection === 'mothership' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx new file mode 100644 index 00000000000..b47d84568bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { SelfHost } from '@/app/workspace/[workspaceId]/settings/components/self-host/self-host' + +describe('SelfHost settings section', () => { + it('links to the managed Chat keys page', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('href="https://www.sim.ai/selfhost/settings/chat-keys"') + }) + + /** + * The section is deliberately only the managed link — no status readouts, + * capability inventories, or environment-variable listings. + */ + it('renders exactly one link and no other controls', () => { + const markup = renderToStaticMarkup() + + expect(markup.match(/ + +
+
+ Managed on sim.ai + + Manage the model-provider keys that power Chat on this deployment. + +
+ + Manage keys + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index ed53da7b24d..9f61a448a94 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -51,6 +51,7 @@ describe('unified settings navigation', () => { { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, { id: 'inbox', label: 'Sim Mailer', section: 'workspace' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' }, + { id: 'self-host', label: 'Self-host', section: 'workspace' }, { id: 'sso', label: 'Single sign-on', section: 'organization' }, { id: 'sessions', label: 'Session policies', section: 'organization' }, { id: 'data-retention', label: 'Data retention', section: 'organization' }, @@ -87,6 +88,7 @@ describe('unified settings navigation', () => { 'apikeys', 'sandboxes', 'recently-deleted', + 'self-host', ]) expect(idsForSection('organization')).toEqual([ 'organization', diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index ca1b92fd241..e2c2a7c73ec 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { resetEnvMock, setEnv } from '@sim/testing' +import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, @@ -32,9 +32,13 @@ import { */ beforeEach(() => { setEnv({ NEXT_PUBLIC_SANDBOX_ENABLED: 'true', NEXT_PUBLIC_E2B_ENABLED: undefined }) + resetEnvFlagsMock() }) -afterAll(resetEnvMock) +afterAll(() => { + resetEnvMock() + resetEnvFlagsMock() +}) describe('settings navigation boundaries', () => { it('preserves the order of all four settings catalogs', () => { @@ -58,6 +62,7 @@ describe('settings navigation boundaries', () => { 'sandboxes', 'inbox', 'recently-deleted', + 'self-host', 'sso', 'sessions', 'data-retention', @@ -99,6 +104,7 @@ describe('settings navigation boundaries', () => { 'recently-deleted', 'forks', 'custom-blocks', + 'self-host', ]) }) @@ -127,6 +133,50 @@ describe('settings navigation boundaries', () => { ).not.toContain('sandboxes') }) + /** + * The Self-host section links out to the managed service that issues this + * deployment's Chat keys. On Sim Cloud that surface is reached from the + * account plane instead, so the section must not exist there at all — in the + * sidebar catalog or in the workspace-plane gate the route consults. + */ + it('shows the Self-host section only on a self-hosted deployment', () => { + setEnvFlags({ isHosted: false }) + + expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('self-host') + expect( + resolveWorkspaceNavigation({ + permission: 'admin', + permissionConfig: {}, + entitlements: { + byok: true, + inbox: true, + customBlocks: true, + forks: true, + sandboxes: true, + }, + }).map(({ id }) => id) + ).toContain('self-host') + }) + + it('drops the Self-host section on hosted Sim', () => { + setEnvFlags({ isHosted: true }) + + expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).not.toContain('self-host') + expect( + resolveWorkspaceNavigation({ + permission: 'admin', + permissionConfig: {}, + entitlements: { + byok: true, + inbox: true, + customBlocks: true, + forks: true, + sandboxes: true, + }, + }).map(({ id }) => id) + ).not.toContain('self-host') + }) + it('keeps the Sandboxes section on the pre-Daytona E2B flag alone', () => { setEnv({ NEXT_PUBLIC_SANDBOX_ENABLED: undefined, NEXT_PUBLIC_E2B_ENABLED: 'true' }) @@ -367,6 +417,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'custom-blocks', + 'self-host', ], mutable: [], }, @@ -384,6 +435,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'custom-blocks', + 'self-host', ], mutable: ['secrets', 'custom-tools', 'mcp', 'workflow-mcp-servers', 'recently-deleted'], }, @@ -439,6 +491,7 @@ describe('settings navigation boundaries', () => { 'recently-deleted', 'forks', 'custom-blocks', + 'self-host', ]) }) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 1e9a2c79c79..c32dab1cbc0 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -73,6 +73,7 @@ export type WorkspaceSettingsSection = | 'recently-deleted' | 'forks' | 'custom-blocks' + | 'self-host' export type SettingsSection = | AccountSettingsSection @@ -119,6 +120,7 @@ export type UnifiedSettingsSection = | 'data-drains' | 'mothership' | 'recently-deleted' + | 'self-host' export type UnifiedNavigationSection = 'account' | 'workspace' | 'organization' | 'platform' @@ -141,6 +143,12 @@ export interface UnifiedSettingsNavigationItem { requiresEnterprise?: boolean requiresMax?: boolean requiresHosted?: boolean + /** + * The inverse of {@link UnifiedSettingsNavigationItem.requiresHosted}: the + * section exists only on a self-hosted deployment and is absent on Sim Cloud, + * where the same surface is reached from the managed service instead. + */ + requiresSelfHosted?: boolean selfHostedOverride?: boolean requiresSuperUser?: boolean requiresAdminRole?: boolean @@ -680,6 +688,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] workspace: { id: 'recently-deleted', group: 'system', order: 9 }, }, }, + { + label: 'Self-host', + icon: Server, + unified: { + id: 'self-host', + description: 'Manage this deployment from the Sim managed service.', + group: 'workspace', + order: 10, + requiresSelfHosted: true, + }, + planes: { + workspace: { id: 'self-host', group: 'system', order: 12 }, + }, + }, { label: 'Single sign-on', icon: LogIn, @@ -821,6 +843,9 @@ export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[ // `selfHostedOverride` short-circuit would otherwise reveal the tab on a // deployment that has the entitlement but no provider to run what it builds. if (unified.id === 'sandboxes' && !isSandboxExecutionAvailable()) return [] + // Dropped here so the sidebar, the route's `parseSection` gate, and section + // metadata all agree that the section does not exist on Sim Cloud. + if (unified.requiresSelfHosted && isHosted) return [] const { group, ...item } = unified return [ { @@ -996,6 +1021,7 @@ const WORKSPACE_MUTATION_PERMISSION: Record Date: Mon, 3 Aug 2026 12:06:01 -0700 Subject: [PATCH 3/6] improvement(setup): land the wizard handoff on signup A freshly provisioned deployment has no accounts and / renders the marketing landing page, so the bare origin left operators hunting for the CTA. Single-source the URLs and point every open-Sim handoff at /signup across all three modes. --- scripts/setup/lifecycle.ts | 6 +++--- scripts/setup/modes/compose.ts | 11 ++++------- scripts/setup/modes/dev.ts | 3 +-- scripts/setup/modes/k8s.ts | 6 +++--- scripts/setup/urls.ts | 12 ++++++++++++ scripts/setup/wizard.ts | 6 +++--- 6 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 scripts/setup/urls.ts diff --git a/scripts/setup/lifecycle.ts b/scripts/setup/lifecycle.ts index fa2ff6a5275..3ed94657f9d 100644 --- a/scripts/setup/lifecycle.ts +++ b/scripts/setup/lifecycle.ts @@ -8,8 +8,8 @@ import { forwardCommands, isLocalKubeContext } from './modes/k8s.ts' import { httpHealth } from './probes.ts' import * as p from './prompter.ts' import { glyph, theme } from './theme.ts' +import { APP_SIGNUP_URL, APP_URL } from './urls.ts' -const APP_URL = 'http://localhost:3000' const REALTIME_HEALTH = 'http://localhost:3002/health' const POSTGRES_VOLUME = 'sim-postgres-data' const COMPOSE_FILES = ['docker-compose.prod.yml', 'docker-compose.local.yml'] as const @@ -258,7 +258,7 @@ function start(install: Install): void { dockerRun(composeArgs(install, 'up', '-d'), 'docker compose up failed', install.dir) spin.stop('Containers up') p.note( - [`open ${APP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'), + [`open ${APP_SIGNUP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'), 'Running' ) return @@ -312,7 +312,7 @@ function restart(install: Install): void { spin.start('Restarting containers…') dockerRun(composeArgs(install, 'restart'), 'docker compose restart failed', install.dir) spin.stop('Containers restarted') - p.note(`open ${APP_URL}`, 'Running') + p.note(`open ${APP_SIGNUP_URL}`, 'Running') return } if (install.kind === 'dev') { diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 711cbe77ffd..9e60b60bd48 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -19,6 +19,7 @@ import { promptUnlocks, } from '../steps.ts' import { glyph, theme } from '../theme.ts' +import { APP_SIGNUP_URL, APP_URL } from '../urls.ts' const REQUIRED_PORTS = [3000, 3002] as const @@ -118,7 +119,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom if (!quick) { const storage = await promptStorage(root.vars, true) if (storage) Object.assign(values, storage) - const appUrl = root.vars.get('NEXT_PUBLIC_APP_URL') ?? 'http://localhost:3000' + const appUrl = root.vars.get('NEXT_PUBLIC_APP_URL') ?? APP_URL Object.assign(values, await promptSignInProviders(root.vars, appUrl)) Object.assign(values, await promptEmail(root.vars)) const security = await promptSecurity(root.vars) @@ -152,11 +153,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom const spin = p.spinner() spin.start('Waiting for Sim to come up (first run pulls images and migrates)…') - const appHealthy = await waitFor( - () => httpHealth('http://localhost:3000/api/health'), - 300_000, - 3000 - ) + const appHealthy = await waitFor(() => httpHealth(`${APP_URL}/api/health`), 300_000, 3000) const realtimeHealthy = appHealthy && (await waitFor(() => httpHealth('http://localhost:3002/health'), 60_000, 2000)) if (!appHealthy || !realtimeHealthy) { @@ -165,7 +162,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom `${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`, [ `follow the logs: ${theme.command(`docker compose -f ${composeFile} logs -f`)}`, - 'first boots on slow disks can exceed the wait — if containers are still starting, just wait and open http://localhost:3000', + `first boots on slow disks can exceed the wait — if containers are still starting, just wait and open ${APP_SIGNUP_URL}`, ] ) } diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index 4a5d817f1c9..0effc129bcc 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -21,8 +21,7 @@ import { promptUnlocks, } from '../steps.ts' import { glyph, theme } from '../theme.ts' - -const APP_URL = 'http://localhost:3000' +import { APP_URL } from '../urls.ts' /** * A migrate failure on a never-migrated database means setup failed — abort. diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index c76e1c6ba80..68a2d919bec 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -8,8 +8,8 @@ import { waitFor } from '../probes.ts' import * as p from '../prompter.ts' import { chatFlagValues, mothershipOverride, promptCopilotKey } from '../steps.ts' import { glyph, theme } from '../theme.ts' +import { APP_SIGNUP_URL, APP_URL } from '../urls.ts' -const APP_URL = 'http://localhost:3000' const RELEASE = 'sim-dev' const NAMESPACE = 'sim-dev' const LOCAL_CONTEXT_PREFIXES = ['kind-', 'docker-desktop', 'minikube', 'orbstack'] @@ -416,7 +416,7 @@ export async function runK8sMode(detection: Detection): Promise { p.note( [ - `open ${APP_URL} (needs both forwards below)`, + `open ${APP_SIGNUP_URL} (needs both forwards below)`, `pods: kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`, `app logs: kubectl --context ${shq(context)} -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`, // Both, always: the app alone loads but the editor's socket has nothing to @@ -454,7 +454,7 @@ export function forwardCommands(context: string): string[] { */ async function offerPortForward(context: string): Promise { const forward = await p.confirm({ - message: `Port-forward now so you can open ${APP_URL}?`, + message: `Port-forward now so you can open ${APP_SIGNUP_URL}?`, initialValue: true, }) if (!forward) { diff --git a/scripts/setup/urls.ts b/scripts/setup/urls.ts new file mode 100644 index 00000000000..7342df84294 --- /dev/null +++ b/scripts/setup/urls.ts @@ -0,0 +1,12 @@ +/** Origin the wizard provisions Sim on locally, and the base for its env twins. */ +export const APP_URL = 'http://localhost:3000' + +/** + * Where the wizard tells an operator to open Sim. + * + * A freshly provisioned deployment has no accounts yet and `/` renders the + * marketing landing page, so handing over the bare origin leaves the operator + * hunting for a CTA before they can create the first account. Deep-linking to + * signup lands them on the one thing they can actually do. + */ +export const APP_SIGNUP_URL = `${APP_URL}/signup` diff --git a/scripts/setup/wizard.ts b/scripts/setup/wizard.ts index fd62580c87f..76ad0628c44 100644 --- a/scripts/setup/wizard.ts +++ b/scripts/setup/wizard.ts @@ -9,6 +9,7 @@ import { runK8sMode } from './modes/k8s.ts' import { ensurePortsFree } from './ports.ts' import * as p from './prompter.ts' import { glyph, theme } from './theme.ts' +import { APP_SIGNUP_URL } from './urls.ts' export type WizardMode = 'compose' | 'dev' | 'k8s' @@ -159,10 +160,9 @@ export async function runWizard(flags: WizardFlags): Promise { if (mode !== 'k8s' && !startDevNow) await finalVerify() - const url = 'http://localhost:3000' p.note( [ - mode === 'k8s' ? `port-forward, then open ${url}` : `open ${url}`, + mode === 'k8s' ? `port-forward, then open ${APP_SIGNUP_URL}` : `open ${APP_SIGNUP_URL}`, 'manage it: bun run sim start · stop · status · logs', 'check your setup: bun run sim doctor', mode === 'dev' && !startDevNow ? `start Sim: bun run ${devScript}` : null, @@ -175,7 +175,7 @@ export async function runWizard(flags: WizardFlags): Promise { p.outro(theme.accent('Sim is ready.')) if (mode === 'compose' && process.platform === 'darwin') { - spawnSync('open', [url], { stdio: 'ignore' }) + spawnSync('open', [APP_SIGNUP_URL], { stdio: 'ignore' }) } if (startDevNow) { // dev:full binds 3000 (app) and 3002 (realtime) — resolve any conflict From 2fe9e3271943113c81ac127719452fbacfb76f27 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 12:16:51 -0700 Subject: [PATCH 4/6] improvement(settings): mark the self-host section with a sprout Server was already doing double duty for MCP servers and Mothership, and the icon set ships no botanical glyph, so the mark is a text emoji. --- apps/sim/components/icons.tsx | 21 +++++++++++++++++++ .../components/settings/navigation.test.ts | 12 +++++++++++ apps/sim/components/settings/navigation.ts | 4 ++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 606e5b0faf6..42753541a10 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8944,3 +8944,24 @@ export function ZohoDeskIcon(props: SVGProps) { ) } + +/** + * The Self-host settings mark — a sprout, for a deployment you grow yourself. + * + * Text rather than an SVG because the icon set ships no botanical glyph. It is + * consequently the one nav mark that keeps its own colors instead of inheriting + * `--text-icon`, and it renders in the host platform's emoji font. The explicit + * font size pins the glyph to the 14px the line icons around it optically read + * as, independent of whatever the consumer's `size-*` box sets. + */ +export function SproutIcon({ className }: { className?: string }) { + return ( + + 🌱 + + ) +} diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index e2c2a7c73ec..0bbf7176804 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -1,7 +1,9 @@ /** * @vitest-environment node */ +import { createElement } from 'react' import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' +import { renderToStaticMarkup } from 'react-dom/server' import { afterAll, beforeEach, describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, @@ -177,6 +179,16 @@ describe('settings navigation boundaries', () => { ).not.toContain('self-host') }) + /** + * The sprout is a text glyph, not an SVG line icon — a swap back to an icon + * component would silently drop the mark this section is recognized by. + */ + it('marks the Self-host section with the sprout glyph', () => { + const selfHost = buildUnifiedSettingsNavigation().find(({ id }) => id === 'self-host') + + expect(renderToStaticMarkup(createElement(selfHost!.icon, {}))).toContain('\u{1F331}') + }) + it('keeps the Sandboxes section on the pre-Daytona E2B flag alone', () => { setEnv({ NEXT_PUBLIC_SANDBOX_ENABLED: undefined, NEXT_PUBLIC_E2B_ENABLED: 'true' }) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index c32dab1cbc0..27465a63c9c 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -24,7 +24,7 @@ import { Wrench, } from '@sim/emcn/icons' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' -import { CodeIcon, McpIcon } from '@/components/icons' +import { CodeIcon, McpIcon, SproutIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, @@ -690,7 +690,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, { label: 'Self-host', - icon: Server, + icon: SproutIcon, unified: { id: 'self-host', description: 'Manage this deployment from the Sim managed service.', From c4e4f96e781b2d1564a7356363d7af2681f5f474 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 12:37:29 -0700 Subject: [PATCH 5/6] improvement(settings): draw the sprout as an emcn line icon, move to Platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emoji rendered in the platform's own colors, so it was the one glyph in the nav that ignored --text-icon. Replaced with a hand-drawn emcn Sprout (24 grid, 1.55 stroke, currentColor) matching the house style, renamed the tab to Self hosting, and regrouped it under Platform — self-hosting is deployment-wide, not per-workspace. Still self-hosted-only. --- .../[workspaceId]/settings/navigation.test.ts | 5 ++-- apps/sim/components/icons.tsx | 21 --------------- .../components/settings/navigation.test.ts | 12 ++++++--- apps/sim/components/settings/navigation.ts | 11 ++++---- packages/emcn/src/icons/index.ts | 1 + packages/emcn/src/icons/sprout.tsx | 27 +++++++++++++++++++ 6 files changed, 44 insertions(+), 33 deletions(-) create mode 100644 packages/emcn/src/icons/sprout.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 9f61a448a94..efc549b1923 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -51,7 +51,7 @@ describe('unified settings navigation', () => { { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, { id: 'inbox', label: 'Sim Mailer', section: 'workspace' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' }, - { id: 'self-host', label: 'Self-host', section: 'workspace' }, + { id: 'self-host', label: 'Self hosting', section: 'platform' }, { id: 'sso', label: 'Single sign-on', section: 'organization' }, { id: 'sessions', label: 'Session policies', section: 'organization' }, { id: 'data-retention', label: 'Data retention', section: 'organization' }, @@ -88,7 +88,6 @@ describe('unified settings navigation', () => { 'apikeys', 'sandboxes', 'recently-deleted', - 'self-host', ]) expect(idsForSection('organization')).toEqual([ 'organization', @@ -102,7 +101,7 @@ describe('unified settings navigation', () => { 'data-retention', 'data-drains', ]) - expect(idsForSection('platform')).toEqual(['admin', 'mothership']) + expect(idsForSection('platform')).toEqual(['admin', 'mothership', 'self-host']) }) it('derives every unified item from exactly one registry entry', () => { diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 42753541a10..606e5b0faf6 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8944,24 +8944,3 @@ export function ZohoDeskIcon(props: SVGProps) { ) } - -/** - * The Self-host settings mark — a sprout, for a deployment you grow yourself. - * - * Text rather than an SVG because the icon set ships no botanical glyph. It is - * consequently the one nav mark that keeps its own colors instead of inheriting - * `--text-icon`, and it renders in the host platform's emoji font. The explicit - * font size pins the glyph to the 14px the line icons around it optically read - * as, independent of whatever the consumer's `size-*` box sets. - */ -export function SproutIcon({ className }: { className?: string }) { - return ( - - 🌱 - - ) -} diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0bbf7176804..0b05b742ddf 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -180,13 +180,17 @@ describe('settings navigation boundaries', () => { }) /** - * The sprout is a text glyph, not an SVG line icon — a swap back to an icon - * component would silently drop the mark this section is recognized by. + * The mark must be a line icon that inherits `--text-icon` like every other + * nav glyph — an emoji would render in the platform's own colors and be the + * one colored item in a monochrome icon column. */ - it('marks the Self-host section with the sprout glyph', () => { + it('marks the Self hosting section with a currentColor line icon', () => { const selfHost = buildUnifiedSettingsNavigation().find(({ id }) => id === 'self-host') + const markup = renderToStaticMarkup(createElement(selfHost!.icon, {})) - expect(renderToStaticMarkup(createElement(selfHost!.icon, {}))).toContain('\u{1F331}') + expect(selfHost?.label).toBe('Self hosting') + expect(markup).toContain(' { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 27465a63c9c..bf0bb77b306 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -16,6 +16,7 @@ import { Settings, ShieldCheck, Shuffle, + Sprout, TerminalWindow, TrashOutline, Upload, @@ -24,7 +25,7 @@ import { Wrench, } from '@sim/emcn/icons' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' -import { CodeIcon, McpIcon, SproutIcon } from '@/components/icons' +import { CodeIcon, McpIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, @@ -689,13 +690,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Self-host', - icon: SproutIcon, + label: 'Self hosting', + icon: Sprout, unified: { id: 'self-host', description: 'Manage this deployment from the Sim managed service.', - group: 'workspace', - order: 10, + group: 'platform', + order: 2, requiresSelfHosted: true, }, planes: { diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 1f871b6f680..b6017814ca8 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -89,6 +89,7 @@ export { Shuffle } from './shuffle' export { Sim } from './sim' export { Slash } from './slash' export { Split } from './split' +export { Sprout } from './sprout' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' export { Table } from './table' diff --git a/packages/emcn/src/icons/sprout.tsx b/packages/emcn/src/icons/sprout.tsx new file mode 100644 index 00000000000..bfb2fb6c342 --- /dev/null +++ b/packages/emcn/src/icons/sprout.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' + +/** + * Sprout icon component - a seedling with two leaves + * @param props - SVG properties including className, fill, etc. + */ +export function Sprout(props: SVGProps) { + return ( + + ) +} From 1cd3054b161aa1954cdc38742ade2650d9e5bead Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 12:46:38 -0700 Subject: [PATCH 6/6] improvement(settings): drop the section header from self hosting One row does not need a section label, and removing it takes the divider with it. The body is now the Chat keys row and its managed-keys link, nothing else. --- .../components/self-host/self-host.test.tsx | 12 ++++++++++ .../components/self-host/self-host.tsx | 23 ++++++++----------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx index b47d84568bd..d71286efc46 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/self-host/self-host.test.tsx @@ -12,6 +12,18 @@ describe('SelfHost settings section', () => { expect(markup).toContain('href="https://www.sim.ai/selfhost/settings/chat-keys"') }) + /** + * The body is the Chat keys row and nothing else — no section label, and so + * none of `SettingsSection`'s label/divider chrome above it. + */ + it('renders the Chat keys row with no section header', () => { + const markup = renderToStaticMarkup() + + expect(markup.indexOf('Chat keys')).toBeLessThan(markup.indexOf('Managed keys')) + expect(markup).not.toContain(' - -
-
- Managed on sim.ai - - Manage the model-provider keys that power Chat on this deployment. - -
- - Manage keys - +
+
+ Chat keys + + Model-provider keys that power Chat on this deployment. +
- + + Managed keys + +
) }