Skip to content

Commit 3f70096

Browse files
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.
1 parent 1708173 commit 3f70096

24 files changed

Lines changed: 476 additions & 34 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect'
6+
7+
describe('resolvePostSignupDestination', () => {
8+
it('routes to the verify hop when verification is enforceable', () => {
9+
expect(
10+
resolvePostSignupDestination({ emailVerificationEnabled: true, redirectUrl: '' })
11+
).toEqual({ kind: 'verify' })
12+
})
13+
14+
it('keeps the verify hop owning the callback URL when verification is enforceable', () => {
15+
expect(
16+
resolvePostSignupDestination({
17+
emailVerificationEnabled: true,
18+
redirectUrl: '/invite/abc',
19+
})
20+
).toEqual({ kind: 'verify' })
21+
})
22+
23+
/**
24+
* Regression guard: signup used to push `/verify` unconditionally, stranding
25+
* self-hosted deployments with no mail provider on a screen no email can
26+
* satisfy.
27+
*/
28+
it('never routes to verify when no mail provider is configured', () => {
29+
expect(
30+
resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' })
31+
).toEqual({ kind: 'workspace' })
32+
})
33+
34+
it('preserves the callback URL when verification is not enforceable', () => {
35+
expect(
36+
resolvePostSignupDestination({
37+
emailVerificationEnabled: false,
38+
redirectUrl: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz',
39+
})
40+
).toEqual({
41+
kind: 'redirect',
42+
url: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz',
43+
})
44+
})
45+
})
46+
47+
describe('buildAuthCrossLink', () => {
48+
it('carries the invite flow and callback URL across the login/signup hop', () => {
49+
expect(buildAuthCrossLink('/login', { callbackUrl: '/invite/abc', isInviteFlow: true })).toBe(
50+
'/login?invite_flow=true&callbackUrl=%2Finvite%2Fabc'
51+
)
52+
})
53+
54+
it('drops the query entirely when nothing needs carrying', () => {
55+
expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: false })).toBe(
56+
'/signup'
57+
)
58+
})
59+
})

apps/sim/app/(auth)/auth-redirect.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,44 @@
55
*/
66
export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl'
77

8+
/** Route the verify hop lives at, entered only from signup. */
9+
export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true'
10+
11+
/** Default post-auth destination when no callback URL was carried in. */
12+
export const DEFAULT_POST_AUTH_ROUTE = '/workspace'
13+
14+
/**
15+
* Where a successful email signup goes next.
16+
* - `verify`: the verification hop, which owns the post-auth redirect from there
17+
* - `redirect`: the validated callback URL the visitor arrived with
18+
* - `workspace`: the default destination
19+
*/
20+
export type PostSignupDestination =
21+
| { kind: 'verify' }
22+
| { kind: 'redirect'; url: string }
23+
| { kind: 'workspace' }
24+
25+
interface PostSignupDestinationParams {
26+
/** The server-derived effective flag — verification enabled AND deliverable. */
27+
emailVerificationEnabled: boolean
28+
/** Callback URL that already passed `validateCallbackUrl`, or `''`. */
29+
redirectUrl: string
30+
}
31+
32+
/**
33+
* `/verify` is a destination only when the deployment can actually deliver the
34+
* code. A deployment with no mail provider would otherwise strand every new
35+
* account on a screen no email can ever satisfy, so signup continues straight
36+
* to the normal post-auth destination instead.
37+
*/
38+
export function resolvePostSignupDestination({
39+
emailVerificationEnabled,
40+
redirectUrl,
41+
}: PostSignupDestinationParams): PostSignupDestination {
42+
if (emailVerificationEnabled) return { kind: 'verify' }
43+
return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
44+
}
45+
846
interface AuthCrossLinkParams {
947
/** Validated post-auth destination to carry over, or null to drop it. */
1048
callbackUrl: string | null

apps/sim/app/(auth)/signup/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from 'next'
22
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
3+
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
34
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
45
import SignupForm from '@/app/(auth)/signup/signup-form'
56

@@ -24,6 +25,7 @@ export default async function SignupPage() {
2425
microsoftAvailable={microsoftAvailable}
2526
isProduction={isProduction}
2627
emailSignupEnabled={!isEmailSignupDisabled}
28+
emailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
2729
/>
2830
)
2931
}

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { isSsoEnabled } from '@/lib/core/config/env-flags'
1111
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
1212
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1313
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
14-
import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
14+
import {
15+
buildAuthCrossLink,
16+
DEFAULT_POST_AUTH_ROUTE,
17+
POST_AUTH_REDIRECT_STORAGE_KEY,
18+
resolvePostSignupDestination,
19+
VERIFY_FROM_SIGNUP_ROUTE,
20+
} from '@/app/(auth)/auth-redirect'
1521
import {
1622
AuthDivider,
1723
AuthField,
@@ -86,6 +92,8 @@ interface SignupFormProps {
8692
microsoftAvailable: boolean
8793
isProduction: boolean
8894
emailSignupEnabled: boolean
95+
/** Server-derived: verification is enabled AND a mail provider is configured. */
96+
emailVerificationEnabled: boolean
8997
}
9098

9199
function SignupFormContent({
@@ -94,6 +102,7 @@ function SignupFormContent({
94102
microsoftAvailable,
95103
isProduction,
96104
emailSignupEnabled,
105+
emailVerificationEnabled,
97106
}: SignupFormProps) {
98107
const router = useRouter()
99108
const searchParams = useSearchParams()
@@ -343,18 +352,29 @@ function SignupFormContent({
343352
logger.error('Failed to refresh session after signup:', sessionError)
344353
}
345354

355+
const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl })
356+
346357
if (typeof window !== 'undefined') {
347-
sessionStorage.setItem('verificationEmail', emailValue)
348-
if (redirectUrl) {
349-
sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
350-
} else {
351-
// Clear any leftover from an earlier signup in this tab — otherwise a
352-
// signup with no callbackUrl inherits the previous CLI/invite destination.
353-
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
358+
// Clear any leftover from an earlier signup in this tab — otherwise a
359+
// signup with no callbackUrl inherits the previous CLI/invite destination.
360+
sessionStorage.removeItem('verificationEmail')
361+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
362+
363+
if (destination.kind === 'verify') {
364+
sessionStorage.setItem('verificationEmail', emailValue)
365+
if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
354366
}
355367
}
356368

357-
router.push('/verify?fromSignup=true')
369+
if (destination.kind === 'verify') {
370+
router.push(VERIFY_FROM_SIGNUP_ROUTE)
371+
} else if (destination.kind === 'redirect') {
372+
// Full navigation, matching the verify hop: the destination (invite, CLI
373+
// handoff) is server-rendered and must see the fresh session cookie.
374+
window.location.href = destination.url
375+
} else {
376+
router.push(DEFAULT_POST_AUTH_ROUTE)
377+
}
358378
} catch (error) {
359379
logger.error('Signup error:', error)
360380
setIsLoading(false)
@@ -488,6 +508,7 @@ export default function SignupPage({
488508
microsoftAvailable,
489509
isProduction,
490510
emailSignupEnabled,
511+
emailVerificationEnabled,
491512
}: SignupFormProps) {
492513
return (
493514
<Suspense
@@ -499,6 +520,7 @@ export default function SignupPage({
499520
microsoftAvailable={microsoftAvailable}
500521
isProduction={isProduction}
501522
emailSignupEnabled={emailSignupEnabled}
523+
emailVerificationEnabled={emailVerificationEnabled}
502524
/>
503525
</Suspense>
504526
)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockHasEmailService, mockIsEmailVerificationEffectivelyEnabled } = vi.hoisted(() => ({
7+
mockHasEmailService: vi.fn<() => boolean>(),
8+
mockIsEmailVerificationEffectivelyEnabled: vi.fn<() => boolean>(),
9+
}))
10+
11+
vi.mock('@/lib/messaging/email/mailer', () => ({
12+
hasEmailService: mockHasEmailService,
13+
}))
14+
15+
vi.mock('@/lib/messaging/email/verification', () => ({
16+
isEmailVerificationEffectivelyEnabled: mockIsEmailVerificationEffectivelyEnabled,
17+
}))
18+
19+
vi.mock('@/app/(auth)/verify/verify-content', () => ({
20+
VerifyContent: () => null,
21+
}))
22+
23+
import VerifyPage from '@/app/(auth)/verify/page'
24+
25+
describe('verify page', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
})
29+
30+
it('renders the verification experience when a mail provider is configured', () => {
31+
mockHasEmailService.mockReturnValue(true)
32+
mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(true)
33+
34+
const element = VerifyPage()
35+
36+
expect(element.props.hasEmailService).toBe(true)
37+
expect(element.props.isEmailVerificationEnabled).toBe(true)
38+
})
39+
40+
/**
41+
* The page hands the effective value down, so the verification form never
42+
* renders on a deployment that cannot deliver a code — it redirects instead.
43+
*/
44+
it('reports verification off when no mail provider is configured', () => {
45+
mockHasEmailService.mockReturnValue(false)
46+
mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(false)
47+
48+
const element = VerifyPage()
49+
50+
expect(element.props.hasEmailService).toBe(false)
51+
expect(element.props.isEmailVerificationEnabled).toBe(false)
52+
})
53+
})

apps/sim/app/(auth)/verify/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next'
2-
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags'
2+
import { isProd } from '@/lib/core/config/env-flags'
33
import { hasEmailService } from '@/lib/messaging/email/mailer'
4+
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
45
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
56

67
export const metadata: Metadata = {
@@ -16,7 +17,7 @@ export default function VerifyPage() {
1617
<VerifyContent
1718
hasEmailService={emailServiceConfigured}
1819
isProduction={isProd}
19-
isEmailVerificationEnabled={isEmailVerificationEnabled}
20+
isEmailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
2021
/>
2122
)
2223
}

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const WORKSPACE_SECTION_MAP: Partial<Record<SettingsSection, WorkspaceSettingsSe
5858
'recently-deleted': 'recently-deleted',
5959
forks: 'forks',
6060
'custom-blocks': 'custom-blocks',
61+
'self-host': 'self-host',
6162
}
6263

6364
const ORGANIZATION_SECTION_MAP: Partial<Record<SettingsSection, OrganizationSettingsSection>> = {

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ const RecentlyDeleted = dynamic(() =>
5555
'@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
5656
).then((m) => m.RecentlyDeleted)
5757
)
58+
const SelfHost = dynamic(() =>
59+
import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host').then(
60+
(m) => m.SelfHost
61+
)
62+
)
5863
const Billing = dynamic(() =>
5964
import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
6065
)
@@ -200,6 +205,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
200205
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
201206
{effectiveSection === 'inbox' && <Inbox />}
202207
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
208+
{effectiveSection === 'self-host' && <SelfHost />}
203209
{effectiveSection === 'admin' && <Admin />}
204210
{effectiveSection === 'mothership' && <Mothership />}
205211
</SettingsSectionProvider>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
import { describe, expect, it } from 'vitest'
6+
import { SelfHost } from '@/app/workspace/[workspaceId]/settings/components/self-host/self-host'
7+
8+
describe('SelfHost settings section', () => {
9+
it('links to the managed Chat keys page', () => {
10+
const markup = renderToStaticMarkup(<SelfHost />)
11+
12+
expect(markup).toContain('href="https://www.sim.ai/selfhost/settings/chat-keys"')
13+
})
14+
15+
/**
16+
* The body is the Chat keys row and nothing else — no section label, and so
17+
* none of `SettingsSection`'s label/divider chrome above it.
18+
*/
19+
it('renders the Chat keys row with no section header', () => {
20+
const markup = renderToStaticMarkup(<SelfHost />)
21+
22+
expect(markup.indexOf('Chat keys')).toBeLessThan(markup.indexOf('Managed keys'))
23+
expect(markup).not.toContain('<section')
24+
expect(markup).not.toContain('bg-[var(--border)]')
25+
})
26+
27+
/**
28+
* The section is deliberately only the managed link — no status readouts,
29+
* capability inventories, or environment-variable listings.
30+
*/
31+
it('renders exactly one link and no other controls', () => {
32+
const markup = renderToStaticMarkup(<SelfHost />)
33+
34+
expect(markup.match(/<a /g)).toHaveLength(1)
35+
expect(markup).not.toContain('<button')
36+
expect(markup).not.toContain('<input')
37+
})
38+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client'
2+
3+
import { ChipLink } from '@sim/emcn'
4+
import { SITE_URL } from '@/lib/core/utils/urls'
5+
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
6+
7+
/** Chat keys are issued by the managed service, not by this deployment. */
8+
const CHAT_KEYS_HREF = `${SITE_URL}/selfhost/settings/chat-keys`
9+
10+
export function SelfHost() {
11+
return (
12+
<SettingsPanel>
13+
<div className='flex items-center justify-between px-2'>
14+
<div className='flex flex-col justify-center gap-[1px]'>
15+
<span className='text-[var(--text-body)] text-sm'>Chat keys</span>
16+
<span className='text-[var(--text-muted)] text-caption'>
17+
Model-provider keys that power Chat on this deployment.
18+
</span>
19+
</div>
20+
<ChipLink href={CHAT_KEYS_HREF} target='_blank' rel='noopener noreferrer'>
21+
Managed keys
22+
</ChipLink>
23+
</div>
24+
</SettingsPanel>
25+
)
26+
}

0 commit comments

Comments
 (0)