From a197bf113fc8fdbb1534bed033687c001eb00a17 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Sat, 9 May 2026 01:16:13 +0000 Subject: [PATCH] feat: add granular signup disable flags (#2765) --- .env.example | 10 +- .../configuration/environment.mdx | 33 +++- .../deployment/docker-compose.mdx | 11 +- .../docs/self-hosting/deployment/docker.mdx | 6 +- .../docs/self-hosting/deployment/railway.mdx | 6 +- apps/remix/app/components/forms/signup.tsx | 164 ++++++++++-------- .../_recipient+/sign.$token+/complete.tsx | 4 +- .../app/routes/_unauthenticated+/signin.tsx | 13 +- .../app/routes/_unauthenticated+/signup.tsx | 43 +++-- docker/README.md | 6 +- docker/production/compose.yml | 4 + .../lib/utils/handle-oauth-callback-url.ts | 10 +- .../handle-oauth-organisation-callback-url.ts | 9 + packages/auth/server/routes/email-password.ts | 5 +- packages/lib/constants/auth.ts | 21 +++ packages/tsconfig/process-env.d.ts | 4 + render.yaml | 8 + turbo.json | 4 + 18 files changed, 245 insertions(+), 116 deletions(-) diff --git a/.env.example b/.env.example index f92132f2b..a8cab596e 100644 --- a/.env.example +++ b/.env.example @@ -160,8 +160,16 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso" NEXT_PUBLIC_POSTHOG_KEY="" # OPTIONAL: Leave blank to disable billing. NEXT_PUBLIC_FEATURE_BILLING_ENABLED= -# OPTIONAL: Leave blank to allow users to signup through /signup page. +# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal). NEXT_PUBLIC_DISABLE_SIGNUP= +# OPTIONAL: Set to "true" to disable email/password signup only. +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP= +# OPTIONAL: Set to "true" to block new-account creation through Google. Existing linked users can still sign in. +NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP= +# OPTIONAL: Set to "true" to block new-account creation through Microsoft. Existing linked users can still sign in. +NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP= +# OPTIONAL: Set to "true" to block new-account creation through OIDC (including the organisation portal). +NEXT_PUBLIC_DISABLE_OIDC_SIGNUP= # OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org). NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS= # OPTIONAL: Set to true to use internal webapp url in browserless requests. diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 29508a92e..699e54ede 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -224,28 +224,41 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con ## Feature Flags -| Variable | Description | Default | -| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` | -| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | | +| Variable | Description | Default | +| -------------------------------------------- | ----------------------------------------------------------------------------------- | ------- | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Disable all signup methods application-wide | `false` | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only. SSO signup is unaffected | `false` | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google. Existing Google-linked users can still sign in | `false` | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` | +| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | | | `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | | | `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` | ### Signup Restrictions -You can control who is allowed to create accounts on your instance using two environment variables: +You can control who is allowed to create accounts on your instance with the following environment variables: -- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups. +- **`NEXT_PUBLIC_DISABLE_SIGNUP`** (master switch): Set to `true` to block all new signups across every method (email/password, Google, Microsoft, OIDC). When set, this also blocks new-account creation through the organisation OIDC authentication portal. +- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`**: Set to `true` to disable email/password signup only. SSO signup is still allowed. +- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`**: Set to `true` to block brand-new account creation through the matching SSO provider. Existing users with the provider already linked can still sign in, and existing users can still link the provider to their account. `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` also blocks new-account creation through the organisation authentication portal. - **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains. -Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error. +Sign-in for existing users is never affected — only the creation of brand-new accounts. -When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list. +Both the master switch and the domain allowlist apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error. + +When both the master switch and the domain allowlist are set, the master switch takes precedence. Signups are blocked regardless of the domain list. ```bash # Allow signups only from specific domains NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org" +# Allow OIDC signup only; block email/password, Google, Microsoft +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true" +NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true" +NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true" + # Or disable signups entirely NEXT_PUBLIC_DISABLE_SIGNUP="true" ``` @@ -371,6 +384,10 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" # Signup restrictions (optional) # NEXT_PUBLIC_DISABLE_SIGNUP="true" +# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true" +# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true" +# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true" +# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true" # NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org" ``` diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index 4f5cac284..b3590a7f2 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -155,7 +155,13 @@ PORT=3000 NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password # Signup restrictions (optional) +# Master switch — disables every signup method NEXT_PUBLIC_DISABLE_SIGNUP=false +# Per-method switches (optional). Each disables brand-new account creation through that method. +# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true +# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=true +# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true +# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true # NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org ``` @@ -252,7 +258,10 @@ Navigate to the signup page and create your account. Verify your email address All accounts created through signup are regular user accounts. Admin access must be granted directly in the database. Once your accounts are set up, consider disabling public signups by - setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with + setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`. For finer control, use the per-method switches + `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`, `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`, + `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`, `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`, or restrict + signups to specific email domains with `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`. diff --git a/apps/docs/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 18c80ec31..90159be0f 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -100,7 +100,11 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment). diff --git a/apps/docs/content/docs/self-hosting/deployment/railway.mdx b/apps/docs/content/docs/self-hosting/deployment/railway.mdx index f1e3151a9..93c861678 100644 --- a/apps/docs/content/docs/self-hosting/deployment/railway.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/railway.mdx @@ -153,7 +153,11 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com | Variable | Description | Default | | --------------------------------- | ---------------------------------- | ------- | | `PORT` | Application port | `3000` | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - | | `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` | diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 21fcad504..dea82be17 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -58,18 +58,20 @@ export type TSignUpFormSchema = z.infer; export type SignUpFormProps = { className?: string; initialEmail?: string; - isGoogleSSOEnabled?: boolean; - isMicrosoftSSOEnabled?: boolean; - isOIDCSSOEnabled?: boolean; + isEmailPasswordSignupEnabled?: boolean; + isGoogleSignupEnabled?: boolean; + isMicrosoftSignupEnabled?: boolean; + isOidcSignupEnabled?: boolean; returnTo?: string; }; export const SignUpForm = ({ className, initialEmail, - isGoogleSSOEnabled, - isMicrosoftSSOEnabled, - isOIDCSSOEnabled, + isEmailPasswordSignupEnabled = true, + isGoogleSignupEnabled, + isMicrosoftSignupEnabled, + isOidcSignupEnabled, returnTo, }: SignUpFormProps) => { const { _ } = useLingui(); @@ -86,7 +88,7 @@ export const SignUpForm = ({ const [captchaToken, setCaptchaToken] = useState(null); - const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled; + const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled; const form = useForm({ values: { @@ -145,7 +147,7 @@ export const SignUpForm = ({ const onSignUpWithGoogleClick = async () => { try { await authClient.google.signIn(); - } catch (err) { + } catch { toast({ title: _(msg`An unknown error occurred`), description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`), @@ -157,7 +159,7 @@ export const SignUpForm = ({ const onSignUpWithMicrosoftClick = async () => { try { await authClient.microsoft.signIn(); - } catch (err) { + } catch { toast({ title: _(msg`An unknown error occurred`), description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`), @@ -169,7 +171,7 @@ export const SignUpForm = ({ const onSignUpWithOIDCClick = async () => { try { await authClient.oidc.signIn(); - } catch (err) { + } catch { toast({ title: _(msg`An unknown error occurred`), description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`), @@ -235,72 +237,80 @@ export const SignUpForm = ({
- ( - - - Full Name - - - - - - - )} - /> + {isEmailPasswordSignupEnabled && ( + <> + ( + + + Full Name + + + + + + + )} + /> - ( - - - Email Address - - - - - - - )} - /> + ( + + + Email Address + + + + + + + )} + /> - ( - - - Password - + ( + + + Password + - - - + + + - - - )} - /> + + + )} + /> - ( - - - Sign Here - - - onChange(v ?? '')} /> - + ( + + + Sign Here + + + onChange(v ?? '')} + /> + - - - )} - /> + + + )} + /> + + )} {turnstileSiteKey && ( )} - {isGoogleSSOEnabled && ( + {isGoogleSignupEnabled && (
- + {isEmailPasswordSignupEnabled && ( + + )}

diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx index cc546ec89..ed57ba9b7 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx @@ -1,6 +1,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; @@ -8,7 +9,6 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; -import { env } from '@documenso/lib/utils/env'; import { trpc } from '@documenso/trpc/react'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; @@ -77,7 +77,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { const recipientName = recipient.name || fields.find((field) => field.type === FieldType.NAME)?.customText || recipient.email; - const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true'; + const canSignUp = !isExistingUser && isSignupEnabledForProvider('email'); const canRedirectToFolder = user && document.userId === user.id && document.folderId && document.team?.url; diff --git a/apps/remix/app/routes/_unauthenticated+/signin.tsx b/apps/remix/app/routes/_unauthenticated+/signin.tsx index e497540a8..5ea5a5398 100644 --- a/apps/remix/app/routes/_unauthenticated+/signin.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signin.tsx @@ -3,9 +3,9 @@ import { IS_GOOGLE_SSO_ENABLED, IS_MICROSOFT_SSO_ENABLED, IS_OIDC_SSO_ENABLED, + isSignupEnabledForProvider, OIDC_PROVIDER_LABEL, } from '@documenso/lib/constants/auth'; -import { env } from '@documenso/lib/utils/env'; import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { msg } from '@lingui/core/macro'; @@ -32,6 +32,11 @@ export async function loader({ request }: Route.LoaderArgs) { const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED; const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const oidcProviderLabel = OIDC_PROVIDER_LABEL; + const isSignupEnabled = + isSignupEnabledForProvider('email') || + (IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) || + (IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft')) || + (IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc')); let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined; @@ -45,13 +50,15 @@ export async function loader({ request }: Route.LoaderArgs) { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, + isSignupEnabled, oidcProviderLabel, returnTo, }; } export default function SignIn({ loaderData }: Route.ComponentProps) { - const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel, returnTo } = loaderData; + const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } = + loaderData; const { _ } = useLingui(); @@ -95,7 +102,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { returnTo={returnTo} /> - {!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && ( + {!isEmbeddedRedirect && isSignupEnabled && (

Don't have an account?{' '} diff --git a/apps/remix/app/routes/_unauthenticated+/signup.tsx b/apps/remix/app/routes/_unauthenticated+/signup.tsx index 884aaeffb..f470004d2 100644 --- a/apps/remix/app/routes/_unauthenticated+/signup.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signup.tsx @@ -1,5 +1,9 @@ -import { IS_GOOGLE_SSO_ENABLED, IS_MICROSOFT_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth'; -import { env } from '@documenso/lib/utils/env'; +import { + IS_GOOGLE_SSO_ENABLED, + IS_MICROSOFT_SSO_ENABLED, + IS_OIDC_SSO_ENABLED, + isSignupEnabledForProvider, +} from '@documenso/lib/constants/auth'; import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to'; import { msg } from '@lingui/core/macro'; import { redirect } from 'react-router'; @@ -14,14 +18,15 @@ export function meta() { } export function loader({ request }: Route.LoaderArgs) { - const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP'); + const isEmailPasswordSignupEnabled = isSignupEnabledForProvider('email'); + const isGoogleSignupEnabled = IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google'); + const isMicrosoftSignupEnabled = IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft'); + const isOidcSignupEnabled = IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc'); - // SSR env variables. - const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; - const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED; - const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; + const isAnySignupEnabled = + isEmailPasswordSignupEnabled || isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled; - if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + if (!isAnySignupEnabled) { throw redirect('/signin'); } @@ -30,22 +35,30 @@ export function loader({ request }: Route.LoaderArgs) { returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined; return { - isGoogleSSOEnabled, - isMicrosoftSSOEnabled, - isOIDCSSOEnabled, + isEmailPasswordSignupEnabled, + isGoogleSignupEnabled, + isMicrosoftSignupEnabled, + isOidcSignupEnabled, returnTo, }; } export default function SignUp({ loaderData }: Route.ComponentProps) { - const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData; + const { + isEmailPasswordSignupEnabled, + isGoogleSignupEnabled, + isMicrosoftSignupEnabled, + isOidcSignupEnabled, + returnTo, + } = loaderData; return ( ); diff --git a/docker/README.md b/docker/README.md index e6564262f..f6c549760 100644 --- a/docker/README.md +++ b/docker/README.md @@ -253,5 +253,9 @@ Here's a markdown table documenting all the provided environment variables: | `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. | | `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). | | `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Set to `true` to disable all signup methods (incl. organisation OIDC portal). | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Set to `true` to disable email/password signup only. SSO signup is unaffected. | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Set to `true` to block new accounts via Google. Existing Google-linked users can still sign in. | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Set to `true` to block new accounts via Microsoft. Existing linked users can still sign in. | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Set to `true` to block new accounts via OIDC (incl. organisation portal). Existing users unaffected.| | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). | diff --git a/docker/production/compose.yml b/docker/production/compose.yml index 55e19294a..b84e9bf65 100644 --- a/docker/production/compose.yml +++ b/docker/production/compose.yml @@ -59,6 +59,10 @@ services: - NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP} + - NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP} + - NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP} + - NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP} + - NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP} - NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS} - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12} - NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE} diff --git a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts index abfa612f6..a8a9abe44 100644 --- a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts @@ -1,10 +1,12 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth'; +import { + isEmailDomainAllowedForSignup, + isSignupEnabledForProvider, +} from '@documenso/lib/constants/auth'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user'; import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account'; import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account'; -import { env } from '@documenso/lib/utils/env'; import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to'; import { prisma } from '@documenso/prisma'; import { UserSecurityAuditLogType } from '@prisma/client'; @@ -115,8 +117,8 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti return c.redirect(redirectPath, 302); } - // Check if signups are disabled. - if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') { + // Check if signups are disabled for this provider. + if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) { const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL()); errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled); diff --git a/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts index 92baedc5d..fbf99953f 100644 --- a/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts @@ -1,4 +1,5 @@ import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email'; +import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth'; import { AppError } from '@documenso/lib/errors/app-error'; import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user'; import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal'; @@ -65,6 +66,14 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg // Handle new user. if (!userToLink) { + if (!isSignupEnabledForProvider('oidc')) { + const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl)); + + errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled); + + return c.redirect(errorUrl.toString(), 302); + } + userToLink = await prisma.user.create({ data: { email: email, diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index fa79cd76b..7ea0584ef 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -1,4 +1,4 @@ -import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth'; +import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth'; import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; import { AppError } from '@documenso/lib/errors/app-error'; import { jobsClient } from '@documenso/lib/jobs/client'; @@ -27,7 +27,6 @@ import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/serv import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { verifyEmail } from '@documenso/lib/server-only/user/verify-email'; -import { env } from '@documenso/lib/utils/env'; import { prisma } from '@documenso/prisma'; import { sValidator } from '@hono/standard-validator'; import { compare } from '@node-rs/bcrypt'; @@ -184,7 +183,7 @@ export const emailPasswordRoute = new Hono() .post('/signup', sValidator('json', ZSignUpSchema), async (c) => { const requestMetadata = c.get('requestMetadata'); - if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') { + if (!isSignupEnabledForProvider('email')) { throw new AppError(AuthenticationErrorCode.SignupDisabled, { statusCode: 400, }); diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 53614fa80..850c74e73 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -119,3 +119,24 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => { return allowedDomains.includes(emailDomain); }; + +/** + * Check if signup is enabled for the given provider. + * The master switch takes precedence over the per-provider flags. + */ +export const isSignupEnabledForProvider = ( + provider: 'email' | 'google' | 'microsoft' | 'oidc', +): boolean => { + if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') { + return false; + } + + const flagMap = { + email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP', + google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP', + microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP', + oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNUP', + } as const; + + return env(flagMap[provider]) !== 'true'; +}; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 16be71dc1..bb29fb271 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -74,6 +74,10 @@ declare namespace NodeJS { NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string; NEXT_PUBLIC_DISABLE_SIGNUP?: string; + NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP?: string; + NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP?: string; + NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP?: string; + NEXT_PUBLIC_DISABLE_OIDC_SIGNUP?: string; NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS?: string; NEXT_PRIVATE_BROWSERLESS_URL?: string; diff --git a/render.yaml b/render.yaml index 902672b0e..1811be308 100644 --- a/render.yaml +++ b/render.yaml @@ -155,6 +155,14 @@ services: # Features Optional - key: NEXT_PUBLIC_DISABLE_SIGNUP sync: false + - key: NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP + sync: false + - key: NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP + sync: false + - key: NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP + sync: false + - key: NEXT_PUBLIC_DISABLE_OIDC_SIGNUP + sync: false - key: NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS sync: false diff --git a/turbo.json b/turbo.json index 2a0bbff9c..8fe691130 100644 --- a/turbo.json +++ b/turbo.json @@ -48,6 +48,10 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_DISABLE_SIGNUP", + "NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP", + "NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP", + "NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP", + "NEXT_PUBLIC_DISABLE_OIDC_SIGNUP", "NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS", "NEXT_PRIVATE_PLAIN_API_KEY", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",