Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apps/sim/app/(auth)/auth-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 38 additions & 0 deletions apps/sim/app/(auth)/auth-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -24,6 +25,7 @@ export default async function SignupPage() {
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={!isEmailSignupDisabled}
emailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
/>
)
}
40 changes: 31 additions & 9 deletions apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -94,6 +102,7 @@ function SignupFormContent({
microsoftAvailable,
isProduction,
emailSignupEnabled,
emailVerificationEnabled,
}: SignupFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -488,6 +508,7 @@ export default function SignupPage({
microsoftAvailable,
isProduction,
emailSignupEnabled,
emailVerificationEnabled,
}: SignupFormProps) {
return (
<Suspense
Expand All @@ -499,6 +520,7 @@ export default function SignupPage({
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={emailSignupEnabled}
emailVerificationEnabled={emailVerificationEnabled}
/>
</Suspense>
)
Expand Down
53 changes: 53 additions & 0 deletions apps/sim/app/(auth)/verify/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
5 changes: 3 additions & 2 deletions apps/sim/app/(auth)/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -16,7 +17,7 @@ export default function VerifyPage() {
<VerifyContent
hasEmailService={emailServiceConfigured}
isProduction={isProd}
isEmailVerificationEnabled={isEmailVerificationEnabled}
isEmailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading