diff --git a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx
index 15bcd34c7f7..d997438d09b 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx
@@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
| Variable | Effect |
|---|---|
-| `DISABLE_REGISTRATION=true` | Blocks email/password registration |
+| `DISABLE_REGISTRATION=true` | Blocks all new accounts — email/password, email OTP, and social sign-in. Only existing accounts can sign in, including to accept a workspace invitation. SSO is unaffected |
| `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working |
| `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup |
| `ALLOWED_LOGIN_EMAILS` | Comma-separated address allowlist, applied the same way |
@@ -93,7 +93,9 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
| `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above |
- These controls gate the **email/password** path. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. If you need a hard boundary, disable the social providers you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO.
+ `ALLOWED_LOGIN_DOMAINS`, `ALLOWED_LOGIN_EMAILS`, and `SIGNUP_MX_VALIDATION_ENABLED` gate the **email/password** path only. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. To restrict who may sign in through a social provider, disable the ones you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO.
+
+ `DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included.
For a company deployment, the usual pairing is domain-restricted signup plus SSO:
diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
index 8a2f01bf27f..3835925ca00 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
@@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication).
| Variable | Description |
|----------|-------------|
-| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely |
+| `DISABLE_REGISTRATION` | Set `true` to block all new accounts, including social sign-in. Invitations still work for people who already have an account. SSO is unaffected |
| `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working |
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts
index e4b9e25b3df..93a94dd6eae 100644
--- a/apps/sim/app/(auth)/auth-redirect.test.ts
+++ b/apps/sim/app/(auth)/auth-redirect.test.ts
@@ -2,7 +2,11 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
-import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect'
+import {
+ buildAuthCrossLink,
+ resolveAuthRedirect,
+ resolvePostSignupDestination,
+} from '@/app/(auth)/auth-redirect'
describe('resolvePostSignupDestination', () => {
it('routes to the verify hop when verification is enforceable', () => {
@@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => {
'/signup'
)
})
+
+ it('marks a new user so the invite page leads with account creation', () => {
+ expect(
+ buildAuthCrossLink('/signup', {
+ callbackUrl: '/invite/abc',
+ isInviteFlow: true,
+ isNewUser: true,
+ })
+ ).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true')
+ })
+
+ it('omits the new-user marker by default', () => {
+ expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain(
+ 'new=true'
+ )
+ })
+})
+
+describe('resolveAuthRedirect', () => {
+ const NONE = { redirect: null, callbackUrl: null, inviteFlow: null }
+
+ it('prefers redirect over callbackUrl', () => {
+ expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe(
+ '/a'
+ )
+ })
+
+ it('falls through an empty redirect to callbackUrl', () => {
+ expect(
+ resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl
+ ).toBe('/invite/abc')
+ })
+
+ it('reports no destination when nothing was carried', () => {
+ expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false })
+ })
+
+ it('treats an invitation destination as an invite flow without the flag', () => {
+ expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true)
+ })
+
+ it('honors the explicit flag when the destination is unrelated', () => {
+ expect(
+ resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow
+ ).toBe(true)
+ })
})
diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts
index 0cfd1310b22..269528c0bb1 100644
--- a/apps/sim/app/(auth)/auth-redirect.ts
+++ b/apps/sim/app/(auth)/auth-redirect.ts
@@ -43,10 +43,43 @@ export function resolvePostSignupDestination({
return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
}
+/** The raw redirect-carrying params, as read from a URL on client or server. */
+interface AuthRedirectParams {
+ redirect: string | null
+ callbackUrl: string | null
+ inviteFlow: string | null
+}
+
+/**
+ * The post-auth destination a visitor arrived with, and whether they are mid
+ * invitation.
+ *
+ * `redirect` wins over `callbackUrl` — both spellings are in circulation. The
+ * invite flow is inferred from the destination as well as the explicit flag, so
+ * a link that lost `invite_flow` still reads as an invitation.
+ *
+ * Shared so the signup form and the registration-disabled page cannot drift on
+ * which param wins; both feed the result to {@link buildAuthCrossLink}. The
+ * caller validates — this function does not, so that a client can log the
+ * rejection it already reports.
+ */
+export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): {
+ rawCallbackUrl: string
+ isInviteFlow: boolean
+} {
+ const rawCallbackUrl = redirect || callbackUrl || ''
+ return {
+ rawCallbackUrl,
+ isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'),
+ }
+}
+
interface AuthCrossLinkParams {
/** Validated post-auth destination to carry over, or null to drop it. */
callbackUrl: string | null
isInviteFlow: boolean
+ /** Marks the visitor as new so the invite page leads with account creation. */
+ isNewUser?: boolean
}
/**
@@ -57,11 +90,12 @@ interface AuthCrossLinkParams {
*/
export function buildAuthCrossLink(
path: '/login' | '/signup',
- { callbackUrl, isInviteFlow }: AuthCrossLinkParams
+ { callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams
): string {
const params = new URLSearchParams()
if (isInviteFlow) params.set('invite_flow', 'true')
if (callbackUrl) params.set('callbackUrl', callbackUrl)
+ if (isNewUser) params.set('new', 'true')
const query = params.toString()
return query ? `${path}?${query}` : path
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index a2349c428eb..380aeb89a42 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -88,11 +88,14 @@ export default function LoginPage({
googleAvailable,
microsoftAvailable,
isProduction,
+ registrationDisabled,
}: {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
isProduction: boolean
+ /** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */
+ registrationDisabled: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -436,7 +439,7 @@ export default function LoginPage({
)}
- {emailEnabled && (
+ {emailEnabled && !registrationDisabled && (
)}
diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx
index ecec5ceb41a..1490ac85e44 100644
--- a/apps/sim/app/(auth)/login/page.tsx
+++ b/apps/sim/app/(auth)/login/page.tsx
@@ -1,5 +1,6 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
+import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
@@ -20,6 +21,7 @@ export default async function LoginPage() {
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
+ registrationDisabled={isRegistrationDisabled}
/>
)
diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx
index 3d5a8933cd6..d43dc7c0475 100644
--- a/apps/sim/app/(auth)/signup/page.tsx
+++ b/apps/sim/app/(auth)/signup/page.tsx
@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
+import type { SearchParams } from 'nuqs/server'
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
+import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
+import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
+import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled'
+import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params'
import SignupForm from '@/app/(auth)/signup/signup-form'
export const metadata: Metadata = {
@@ -10,9 +15,25 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic'
-export default async function SignupPage() {
+export default async function SignupPage({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
if (isRegistrationDisabled) {
- return
Registration is disabled, please contact your admin.
+ const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams)
+ const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({
+ redirect,
+ callbackUrl,
+ inviteFlow,
+ })
+
+ return (
+
+ )
}
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
diff --git a/apps/sim/app/(auth)/signup/registration-disabled.tsx b/apps/sim/app/(auth)/signup/registration-disabled.tsx
new file mode 100644
index 00000000000..2096c056506
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/registration-disabled.tsx
@@ -0,0 +1,31 @@
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components'
+
+interface RegistrationDisabledProps {
+ /** Post-auth destination the visitor arrived with, already validated. */
+ callbackUrl: string | null
+ isInviteFlow: boolean
+}
+
+/**
+ * The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale
+ * link, a bookmark, or an invitation, so it wears the same shell as the form it
+ * replaces and carries the post-auth destination over to login — an invited
+ * visitor who lands here can still sign in and end up back on their invitation
+ * rather than losing it.
+ */
+export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) {
+ return (
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(auth)/signup/search-params.ts b/apps/sim/app/(auth)/signup/search-params.ts
new file mode 100644
index 00000000000..54c247dc2a9
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/search-params.ts
@@ -0,0 +1,23 @@
+import { createSearchParamsCache, parseAsString } from 'nuqs/server'
+
+/**
+ * The redirect signals the signup page carries. Read once to decide where a
+ * visitor goes after authenticating, never written, so every parser is nullable
+ * with no default — absent means "no destination", which is a real state rather
+ * than something to fall back from.
+ */
+const signupParsers = {
+ redirect: parseAsString,
+ callbackUrl: parseAsString,
+ inviteFlow: parseAsString,
+} as const
+
+/** `invite_flow` on the wire; camelCase for destructuring. */
+const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const
+
+/**
+ * Server-side reader for the signup page. The client form reads these same keys
+ * through `useSearchParams` (the read-once auth-signal carve-out), so the wire
+ * keys here and in `signup-form.tsx` must stay in step.
+ */
+export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys)
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 4915da788b3..ad0d5213b97 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
+import { Suspense, useEffect, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -15,6 +15,7 @@ import {
buildAuthCrossLink,
DEFAULT_POST_AUTH_ROUTE,
POST_AUTH_REDIRECT_STORAGE_KEY,
+ resolveAuthRedirect,
resolvePostSignupDestination,
VERIFY_FROM_SIGNUP_ROUTE,
} from '@/app/(auth)/auth-redirect'
@@ -123,7 +124,11 @@ function SignupFormContent({
const [formError, setFormError] = useState(null)
const turnstileRef = useRef(null)
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
- const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
+ const { rawCallbackUrl: rawRedirectUrl, isInviteFlow } = resolveAuthRedirect({
+ redirect: searchParams.get('redirect'),
+ callbackUrl: searchParams.get('callbackUrl'),
+ inviteFlow: searchParams.get('invite_flow'),
+ })
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
const invalidCallbackRef = useRef(false)
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
@@ -131,10 +136,6 @@ function SignupFormContent({
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
}
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
- const isInviteFlow = useMemo(
- () => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'),
- [searchParams, redirectUrl]
- )
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState([])
diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx
index b1e27f22608..14463bf4abb 100644
--- a/apps/sim/app/(auth)/sso/page.tsx
+++ b/apps/sim/app/(auth)/sso/page.tsx
@@ -1,7 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
-import { isSsoEnabled } from '@/lib/core/config/env-flags'
+import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
@@ -17,7 +17,7 @@ export default async function SSOPage() {
return (
-
+
)
}
diff --git a/apps/sim/app/cli/auth/page.test.tsx b/apps/sim/app/cli/auth/page.test.tsx
new file mode 100644
index 00000000000..ce1cbcd7d86
--- /dev/null
+++ b/apps/sim/app/cli/auth/page.test.tsx
@@ -0,0 +1,71 @@
+/**
+ * @vitest-environment node
+ */
+import { envFlagsMock } from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession, mockRedirect } = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockRedirect: vi.fn((url: string) => {
+ throw new Error(`NEXT_REDIRECT:${url}`)
+ }),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: mockRedirect,
+}))
+
+import CliAuthPage from '@/app/cli/auth/page'
+
+/** BASE64URL, 43 chars; pairing is `XXXX-XXXX` over the no-look-alike alphabet. */
+const REQUEST = 'r'.repeat(43)
+const CHALLENGE = 'c'.repeat(43)
+const PAIRING = 'ABCD-2345'
+
+const EXPECTED_CALLBACK = encodeURIComponent(
+ `/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}`
+)
+
+function pageProps() {
+ return {
+ searchParams: Promise.resolve({
+ request: REQUEST,
+ challenge: CHALLENGE,
+ pairing: PAIRING,
+ }),
+ }
+}
+
+describe('CliAuthPage signed-out bounce', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue(null)
+ })
+
+ afterEach(() => {
+ envFlagsMock.isRegistrationDisabled = false
+ })
+
+ it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => {
+ await expect(CliAuthPage(pageProps())).rejects.toThrow(
+ `NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}`
+ )
+ })
+
+ /**
+ * Nobody can create an account under the flag, so the pairing visitor is
+ * necessarily an existing user and signup would be a guaranteed dead end.
+ */
+ it('sends them to login instead when registration is disabled', async () => {
+ envFlagsMock.isRegistrationDisabled = true
+
+ await expect(CliAuthPage(pageProps())).rejects.toThrow(
+ `NEXT_REDIRECT:/login?callbackUrl=${EXPECTED_CALLBACK}`
+ )
+ })
+})
diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx
index 2bc0d86370a..e49a47be4cc 100644
--- a/apps/sim/app/cli/auth/page.tsx
+++ b/apps/sim/app/cli/auth/page.tsx
@@ -3,6 +3,8 @@ import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import type { SearchParams } from 'nuqs/server'
import { getSession } from '@/lib/auth'
+import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { AuthShell } from '@/app/(auth)/components'
import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request'
import { CliAuthView } from '@/app/cli/auth/cli-auth-view'
@@ -30,6 +32,10 @@ export const dynamic = 'force-dynamic'
* configuring Sim for the first time has no account yet. Both auth pages
* cross-link carrying the same `callbackUrl`, so a returning user is one click
* from login with their destination intact.
+ *
+ * That reasoning inverts under DISABLE_REGISTRATION: nobody can create an
+ * account, so the pairing visitor is necessarily an existing user and signup is
+ * guaranteed to be the wrong hop. Go straight to login there.
*/
export default async function CliAuthPage({
searchParams,
@@ -49,7 +55,12 @@ export default async function CliAuthPage({
challenge: resolution.request.challenge,
pairing: resolution.request.pairing,
})
- redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`)
+ redirect(
+ buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', {
+ callbackUrl: `/cli/auth?${query}`,
+ isInviteFlow: false,
+ })
+ )
}
return (
diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx
index 83fba300438..24363971dc4 100644
--- a/apps/sim/app/invite/[id]/invite.test.tsx
+++ b/apps/sim/app/invite/[id]/invite.test.tsx
@@ -27,9 +27,7 @@ const {
},
mockPush: vi.fn(),
mockRequestJson: vi.fn(),
- mockSearchParams: {
- get: (key: string) => (key === 'token' ? 'token-1' : null),
- },
+ mockSearchParams: { current: new URLSearchParams('token=token-1') },
mockSetActive: vi.fn(),
mockSetQueryData: vi.fn(),
mockSignOut: vi.fn(),
@@ -43,7 +41,7 @@ vi.mock('@sim/logger', () => ({
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'invitation-1' }),
useRouter: () => ({ push: mockPush }),
- useSearchParams: () => mockSearchParams,
+ useSearchParams: () => mockSearchParams.current,
}))
vi.mock('@tanstack/react-query', async () => {
@@ -106,15 +104,18 @@ vi.mock('@/app/invite/components', () => ({
InviteLayout: ({ children }: { children: ReactNode }) => children,
InviteStatusCard: ({
actions = [],
+ description,
title,
type,
}: {
actions?: Array<{ label: string; onClick: () => void }>
+ description?: ReactNode
title: string
type: string
}) => (
<>