improvement(self-host): gate email verification on a mail provider, add self-host settings, land setup on signup (#6216)

* 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.

* 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.

* 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.

* 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.

* improvement(settings): draw the sprout as an emcn line icon, move to Platform

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.

* 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.
This commit is contained in:
Theodore Li
2026-08-03 15:58:15 -04:00
committed by GitHub
parent 1708173970
commit 3f70096841
24 changed files with 476 additions and 34 deletions
+59
View File
@@ -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'
)
})
})
+38
View File
@@ -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
+2
View File
@@ -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()}
/>
)
}
+31 -9
View File
@@ -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 (
<Suspense
@@ -499,6 +520,7 @@ export default function SignupPage({
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={emailSignupEnabled}
emailVerificationEnabled={emailVerificationEnabled}
/>
</Suspense>
)
+53
View File
@@ -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)
})
})
+3 -2
View File
@@ -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() {
<VerifyContent
hasEmailService={emailServiceConfigured}
isProduction={isProd}
isEmailVerificationEnabled={isEmailVerificationEnabled}
isEmailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
/>
)
}
@@ -58,6 +58,7 @@ const WORKSPACE_SECTION_MAP: Partial<Record<SettingsSection, WorkspaceSettingsSe
'recently-deleted': 'recently-deleted',
forks: 'forks',
'custom-blocks': 'custom-blocks',
'self-host': 'self-host',
}
const ORGANIZATION_SECTION_MAP: Partial<Record<SettingsSection, OrganizationSettingsSection>> = {
@@ -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' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'self-host' && <SelfHost />}
{effectiveSection === 'admin' && <Admin />}
{effectiveSection === 'mothership' && <Mothership />}
</SettingsSectionProvider>
@@ -0,0 +1,38 @@
/**
* @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(<SelfHost />)
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(<SelfHost />)
expect(markup.indexOf('Chat keys')).toBeLessThan(markup.indexOf('Managed keys'))
expect(markup).not.toContain('<section')
expect(markup).not.toContain('bg-[var(--border)]')
})
/**
* 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(<SelfHost />)
expect(markup.match(/<a /g)).toHaveLength(1)
expect(markup).not.toContain('<button')
expect(markup).not.toContain('<input')
})
})
@@ -0,0 +1,26 @@
'use client'
import { ChipLink } from '@sim/emcn'
import { SITE_URL } from '@/lib/core/utils/urls'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
/** Chat keys are issued by the managed service, not by this deployment. */
const CHAT_KEYS_HREF = `${SITE_URL}/selfhost/settings/chat-keys`
export function SelfHost() {
return (
<SettingsPanel>
<div className='flex items-center justify-between px-2'>
<div className='flex flex-col justify-center gap-[1px]'>
<span className='text-[var(--text-body)] text-sm'>Chat keys</span>
<span className='text-[var(--text-muted)] text-caption'>
Model-provider keys that power Chat on this deployment.
</span>
</div>
<ChipLink href={CHAT_KEYS_HREF} target='_blank' rel='noopener noreferrer'>
Managed keys
</ChipLink>
</div>
</SettingsPanel>
)
}
@@ -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 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' },
@@ -100,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', () => {
@@ -1,7 +1,9 @@
/**
* @vitest-environment node
*/
import { resetEnvMock, setEnv } from '@sim/testing'
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,
@@ -32,9 +34,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 +64,7 @@ describe('settings navigation boundaries', () => {
'sandboxes',
'inbox',
'recently-deleted',
'self-host',
'sso',
'sessions',
'data-retention',
@@ -99,6 +106,7 @@ describe('settings navigation boundaries', () => {
'recently-deleted',
'forks',
'custom-blocks',
'self-host',
])
})
@@ -127,6 +135,64 @@ 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')
})
/**
* 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 hosting section with a currentColor line icon', () => {
const selfHost = buildUnifiedSettingsNavigation().find(({ id }) => id === 'self-host')
const markup = renderToStaticMarkup(createElement(selfHost!.icon, {}))
expect(selfHost?.label).toBe('Self hosting')
expect(markup).toContain('<svg')
expect(markup).toContain('stroke="currentColor"')
})
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 +433,7 @@ describe('settings navigation boundaries', () => {
'inbox',
'recently-deleted',
'custom-blocks',
'self-host',
],
mutable: [],
},
@@ -384,6 +451,7 @@ describe('settings navigation boundaries', () => {
'inbox',
'recently-deleted',
'custom-blocks',
'self-host',
],
mutable: ['secrets', 'custom-tools', 'mcp', 'workflow-mcp-servers', 'recently-deleted'],
},
@@ -439,6 +507,7 @@ describe('settings navigation boundaries', () => {
'recently-deleted',
'forks',
'custom-blocks',
'self-host',
])
})
@@ -16,6 +16,7 @@ import {
Settings,
ShieldCheck,
Shuffle,
Sprout,
TerminalWindow,
TrashOutline,
Upload,
@@ -73,6 +74,7 @@ export type WorkspaceSettingsSection =
| 'recently-deleted'
| 'forks'
| 'custom-blocks'
| 'self-host'
export type SettingsSection =
| AccountSettingsSection
@@ -119,6 +121,7 @@ export type UnifiedSettingsSection =
| 'data-drains'
| 'mothership'
| 'recently-deleted'
| 'self-host'
export type UnifiedNavigationSection = 'account' | 'workspace' | 'organization' | 'platform'
@@ -141,6 +144,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 +689,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
workspace: { id: 'recently-deleted', group: 'system', order: 9 },
},
},
{
label: 'Self hosting',
icon: Sprout,
unified: {
id: 'self-host',
description: 'Manage this deployment from the Sim managed service.',
group: 'platform',
order: 2,
requiresSelfHosted: true,
},
planes: {
workspace: { id: 'self-host', group: 'system', order: 12 },
},
},
{
label: 'Single sign-on',
icon: LogIn,
@@ -821,6 +844,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 +1022,7 @@ const WORKSPACE_MUTATION_PERMISSION: Record<WorkspaceSettingsSection, Permission
'recently-deleted': 'write',
forks: 'admin',
'custom-blocks': 'admin',
'self-host': 'admin',
}
export interface WorkspaceMutationCapabilities {
@@ -1028,6 +1055,8 @@ export function resolveWorkspaceNavigation({
if (item.id === 'custom-blocks' && !entitlements.customBlocks) return []
// Removed, not locked: a missing provider is not something an upgrade fixes.
if (item.id === 'sandboxes' && !isSandboxExecutionAvailable()) return []
// Absent on Sim Cloud, where the managed service owns these settings.
if (item.id === 'self-host' && isHosted) return []
const lockedBy = LOCKABLE_WORKSPACE_SECTIONS[item.id]
const locked = lockedBy !== undefined && !entitlements[lockedBy]
+3 -2
View File
@@ -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', {
@@ -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)
})
})
@@ -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()
}
+1
View File
@@ -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'
+27
View File
@@ -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<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M12 20.5V11' />
<path d='M12 14.75Q4.94 15.12 3.5 8.2Q10.56 7.83 12 14.75Z' />
<path d='M12 11.25Q19.06 11.62 20.5 4.7Q13.44 4.33 12 11.25Z' />
</svg>
)
}
+3 -3
View File
@@ -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') {
+4 -7
View File
@@ -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}`,
]
)
}
+1 -2
View File
@@ -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.
+3 -3
View File
@@ -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<void> {
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<void> {
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) {
+12
View File
@@ -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`
+3 -3
View File
@@ -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<void> {
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<void> {
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